SwiftUI:Demo and code review for Resizable Custom Tab Bar for macOS

Run in Xcode 15

1. 效果展示

2. 代码

2.1 入口

2.2 ContentView

2.3 CustomTabView

3. 代码解析

3.1 入口代码里能有什么?

3.1.1 Protocol 之 App

符合此协议的类型提供了应用程序的结构和行为。

当应用程序启动时,SwiftUI从静态主函数调用应用程序初始化器。

这里我们不需要在应用程序类型中实现初始化器,因为Swift默认已经合成了一个初始化器。

依靠该初始化器是可以的,但如果我们想创建一个,我们可以这样做:

3.1.2 @main

当我们声明的应用程序类型用@main注释时,将调用其静态main()函数。

默认实现以适合平台的方式启动应用程序。我们想在启动时加点什么,我们可以这样做:

3.1.3 那么我们之前的AppDelegate的那些回调函数去哪儿了?

答案是在 @UIApplicationDelegateAdaptor 和 @NSApplicationDelegateAdaptor

让我们以 @UIApplicationDelegateAdaptor 为例子

UIApplicationDelegateAdaptor是一个属性包装器(注解),用于为使用新的SwiftUI生命周期的应用程序提供应用程序委托。使用此属性包装器是可选的,因为应用程序不再需要委托。

3.1.4 什么是 Scene

Scene 场景代表屏幕上的内容区域。每个平台使用场景的方式不同。

在iOS上,屏幕通常包含单个场景,在 iPadOS 通常会有多个场景,例如在使用分屏浏览时,可能会显示多个场景。

在macOS上,每个窗口可能是不同的场景。场景也可以有子场景。例如,在macOS上,每个窗口都是一个场景,但当所有窗口合并到一个具有多个选项卡的窗口中时,每个选项卡仍然有一个场景和另一个包含它们的场景。

可以通过遵守场景协议来创建自定义场景。唯一的要求是实现主体(body)。主体由其他场景组成,这些场景也可以是自定义场景或:WindowGroup、DocumentGroup和Settings。

3.1.5 什么是 WindowGroup

WindowGroup 符合Scene协议,是SwiftUI框架提供的具体类型。其他场景类型有: DocumentGroup, Settings 和WKNotificationScene等;

WindowGroup 是用于呈现一组结构相同的窗口的场景。尽管它们的结构相同,但每个场景和视图层次都有自己的状态。SwiftUI 能够处理不同平台的特定行为,例如在 macOS 和 iPadOS 上同时打开多个窗口。在 macOS 13.0 之前,可以使用一些方法打开新窗口,但从 macOS 13.0 开始,引入了新的环境值 openWindow 来增强 WindowGroup 的功能。WindowGroup 的初始化器有多种,它们可以包含不同类型的标题或 id 参数,并可以为窗口指定默认值。标题用于在用户界面中区分窗口组。

3.2 ContentView 大有学问

状态管理,我们按下不表,我记得之前写过几篇状态管理的文章。iOS 17推出了新的状态管理方式,后面我会再写一篇自己的理解。

3.2.1 GeometryReader 是什么?


GeometryReader 是 SwiftUI 中的一个视图容器,它用于获取其子视图的几何属性,例如大小和位置。

重点:它为其内容提供了一个包含尺寸信息的上下文,从而允许我们根据父视图的尺寸动态调整子视图的布局。

用法示例:
下面是一个简单的示例,它使用 GeometryReader 来创建一个正方形视图,其边长等于父视图的宽度的一半。

额外注意:

  • 性能考虑GeometryReader 可能会导致布局的重新计算,因此应该谨慎使用,以避免不必要的性能开销。(在开始前写代码前考虑性能优化的优先级较低,可以先不考虑,等出现了性能问题我们再说)
  • 布局行为GeometryReader 的尺寸会尽可能的填充其父视图的可用空间。

用途:
GeometryReader 在需要根据父视图的尺寸动态调整视图布局时非常有用,例如创建响应式设计、自适应布局等。

3.2.2 为什么 $0?

在 Swift 中,$0 是一个用于表示闭包(closure)中的第一个参数的快捷方式。当闭包的参数没有显式命名时,可以使用 $0$1$2 等来引用闭包的第一个、第二个、第三个参数,以此类推。这种写法可以让代码更简洁。

GeometryReader {  // 用于获取当前视图的尺寸信息
    let size = $0.size  // 获取当前视图的尺寸

GeometryReader 接受一个闭包,该闭包的参数是一个 GeometryProxy 对象,它包含了当前视图的尺寸信息。通常,我们可以显式地命名这个参数,如下所示:

GeometryReader { geometry in
    let size = geometry.size

但为了简洁,我们代码使用了 $0 来代替 geometry。两种写法的功能是相同的,只是语法上的不同。

3.2.3 ViewThatFits 又是什么?


ViewThatFits是SwiftUI中一个非常有用的视图,它能够帮助我们根据可用的屏幕空间来选择合适的布局。具体来说,ViewThatFits的工作原理如下:

  1. 子视图评估

    • ViewThatFits会按照我们提供给它的顺序来评估其子视图。
    • 它会选择第一个其理想尺寸在约束轴上符合提议大小的子视图。
    • 这意味着我们应该按照优先级顺序提供视图。
  2. 自适应布局

    • 通过ViewThatFits,我们可以让它从几种可能的布局中选择一种,基于什么布局能够适应可用的屏幕空间。
    • 这是一种确保你的应用程序从最大的tvOS屏幕到最小的Apple Watch都能看起来很好的方式。
  3. 空间测量

    • ViewThatFits测量特定轴或两者的可用空间。
    • 它测量第一个视图的大小并将其放置在可用空间中,如果它适合的话。
    • 如果第一个视图不适合可用空间,它会测量第二个视图的大小,并在第二个视图适合的情况下将其放置。
  4. 响应式布局的创建

    • 在iOS 16中,通过ViewThatFits,SwiftUI得以更容易地创建响应式布局。
    • 响应式布局是能够适应可用空间的视图或布局。
    • ViewThatFits是一个容器视图,它让我们定义适合不同大小的子视图列。

ViewThatFits通过以上方式,使得创建自适应不同屏幕和设备的布局变得更为简单和直观。同时,它的使用也极大地简化了响应式布局的设计过程,使得我们能够更专注于布局的内容和交互设计,而不是在处理布局适配上消耗大量时间。

3.2.4 ViewThatFits{}.task{} 是什么

在SwiftUI中,.task 修饰符是用来在视图显示时运行异步任务,并在视图消失时自动取消任务的。
这在 iOS 15 和其它相应的平台版本中引入。它类似于以前的 .onAppear 修饰符,但是特别适用于执行异步任务。

例如,我们可以像这样使用 .task 修饰符:

在上述代码中,当 ContentView 出现时,.task 修饰符中的代码将开始执行。如果 ContentView 消失,那么 .task 中的任务将自动取消。.task 里的代码会被异步执行。这对于启动网络请求、读取文件等耗时操作非常有用,因为它不会阻塞主线程。

3.2.5 为什么SideBar(size)调用了2次?

SideBar(size)在这个代码片段中被调用了两次,分别处于不同的上下文和目的中:

  1. ViewThatFits中的调用
    ViewThatFits视图内,SideBar(size)被首先调用,这意味着它是ViewThatFits试图适配的第一个候选视图。ViewThatFits会检查SideBar(size)是否能够适应当前的视图尺寸,如果可以,它就会被选择和显示。

  2. ScrollView中的调用
    ScrollView内,SideBar(size)再次被调用。这次,它是作为一个可以滚动的内容视图。如果SideBar(size)的内容超出了ScrollView的可视区域,用户可以滚动来查看其余的内容。

这种设计方式提供了一个灵活的布局,可以在不同的上下文和条件下适应SideBar视图。例如,当视图的宽度大于屏幕宽度的一半时(isLargetrue),SideBar视图可能直接在ViewThatFits内显示,而不需要滚动。相反,如果视图的宽度较小(isLargefalse),SideBar视图可能会在ScrollView内显示,允许用户滚动来查看全部内容。

这种方式为开发者提供了一种机制,根据不同的设备和屏幕尺寸展示不同的布局或适应不同的用户界面设计,以实现更好的用户体验。

3.2.6 [新手问]这样 SideBar 不就被渲染了两次吗?为什么没有同时出现两个 SideBar?[核心问题]

ViewThatFits中,SideBarScrollView中的SideBar是作为两个可能的布局选项提供的,但是ViewThatFits会根据提供的条件和空间选择其中一个布局。ViewThatFits的工作原理是,它会评估其子视图(在这种情况下是SideBarScrollView中的SideBar),并选择第一个其理想尺寸在约束轴上符合提议大小的子视图。这意味着,只有一个布局会被选择并渲染到屏幕上,而另一个布局则会被忽略。

在我们的代码中:

  • 如果SideBar(size)的尺寸适合当前的视图空间,它将被选择并渲染,而ScrollView中的SideBar将不会被渲染。
  • 如果SideBar(size)的尺寸不适合当前的视图空间,ViewThatFits将继续评估ScrollView中的SideBar,如果它适合,则它将被选择并渲染。

因此,虽然SideBar(size)被调用了两次,但是只有一个SideBar实例会被渲染到屏幕上。这是通过ViewThatFits的自适应布局逻辑实现的,它确保了在任何给定时间只有一个布局被选择和渲染。

3.2.7 什么是@ViewBuilder?什么情况下才使用@ViewBuilder?

@ViewBuilder 是 SwiftUI 中的一个特性,它允许我们在函数或者计算属性中构建和返回一个动态的视图集合。

使用 @ViewBuilder,我们可以基于条件或循环创建视图,并且它们将被自动组合成一个单一的视图。@ViewBuilder 是一个函数修饰符,它可以将函数体中的多个视图组合成一个复合视图,通常是一个 TupleView 或者某些情况下的其他类型的视图。

下面是一些 @ViewBuilder 可以被用到的情况:

  1. 条件视图
    如果我们想基于某个条件返回不同的视图,我们可以使用 @ViewBuilder。例如,我们可能想要显示一个视图,如果某个条件为真,显示另一个视图,如果条件为假。

  2. 循环视图
    如果我们想要循环遍历一个集合并为集合中的每个元素创建一个视图,你可以使用 @ViewBuilder

  3. 组合视图
    如果我们想要组合多个视图成一个单一的视图,我们可以使用 @ViewBuilder

在上述的例子中,@ViewBuilder 修饰符允许我们在一个函数或计算属性中返回一个动态的视图集合,而不是仅仅返回一个单一的视图。它提供了一种简洁、清晰的方式来构建复杂的视图层次结构,而不需要创建大量的中间视图和辅助结构。这使得我们的代码更为简洁、可读并且更容易维护。同时,@ViewBuilder 的使用也可以帮助优化 SwiftUI 的渲染性能,因为它能够减少不必要的视图创建和更新。

这里我们再补充一些其他关于关于@ViewBuilder

  1. 返回类型:
    使用 @ViewBuilder 修饰的函数或属性,通常会返回 some View 类型。这是因为 @ViewBuilder 可能会根据我们的代码返回不同类型的视图。例如,它可能返回一个 TupleViewEmptyView 或者我们自定义的视图类型。

  2. EmptyView:
    在条件语句中,如果某个条件分支没有对应的视图,@ViewBuilder 会自动使用 EmptyView 来填充那个分支。

  3. 嵌套使用:
    我们可以嵌套使用 @ViewBuilder,这意味着我们可以在一个 @ViewBuilder 修饰的函数或属性中调用另一个 @ViewBuilder 修饰的函数或属性。

  4. 复杂的逻辑:
    我们可以在 @ViewBuilder 修饰的函数或属性中使用复杂的逻辑,包括 switch 语句,guard 语句,以及更复杂的循环和条件语句。

  5. 性能优化:
    @ViewBuilder 是 SwiftUI 的一个性能优化工具。通过减少不必要的视图更新和渲染,它可以帮助提高我们的应用程序的性能。

  6. 自定义容器视图:
    @ViewBuilder 是创建自定义容器视图的关键。通过使用 @ViewBuilder,我们可以创建能够接受和布局多个子视图的自定义视图。

@ViewBuilder 是一个非常强大和灵活的工具,在 SwiftUI 中构建复杂的视图层次结构时,它是不可或缺的。

3.2.8 onContinuousHover 其实是一个 Protocol


在 SwiftUI 中,.onContinuousHover(perform:) 是一个修饰符,它可以用于检测用户的鼠标或指针是否悬停在视图上,并提供持续的反馈。这个修饰符可以用于 macOS 和 iPadOS,它提供了一种简单的方式来响应用户的鼠标或指针交互。

在我们这段代码中:

  1. phase:

    • phase 参数是一个 HoverPhase 枚举值,它指示了鼠标或指针的悬停状态。
    • HoverPhase 有两个可能的值: .hovering.ended.hovering 表示鼠标或指针正在悬停在视图上,.ended 表示鼠标或指针已经停止悬停或离开了视图。
  2. perform:

    • perform 闭包是当鼠标或指针的悬停状态改变时执行的代码块。我们可以在这个闭包中根据 phase 参数的值执行不同的操作。

这个修饰符可以用于创建响应鼠标或指针悬停的交互效果。例如,我们可能想要在用户的鼠标悬停在一个按钮上时改变按钮的颜色,或者显示一个提示信息。

在这个例子中,.onContinuousHover 修饰符用于检测用户是否悬停在按钮上,并根据悬停状态执行不同的操作。

3.3 CustomTabView 自定义视图

3.3.1 T 什么意思?

在我们这段代码中,T 是一个泛型参数,它代表了一个遵循 Hashable 协议的类型。

泛型T被用于 @Binding var selection: T,表示 selection 变量的类型是泛型 T,这使得 CustomTabView 可以处理不同类型的 selection 数据,只要这些类型遵循 Hashable 协议。

同时,泛型 T 也用于 TabView(selection: $selection),以确保 TabViewselection 参数与 CustomTabViewselection 变量类型一致。

这样的设计使得 CustomTabView 有更好的灵活性和可重用性。

另外在 Swift 和 SwiftUI 中,T 并不总是代表泛型参数,但它是一个常用的约定来表示泛型参数。我们可以使用任何有效的标识符来作为泛型参数的名字,例如 TypeParameterElementItem 等。不过,T 作为一个简单、通用的符号,常常被用于泛型编程中,来表示一个类型参数。这个约定使得代码更简洁,也更容易被其他开发者理解。

3.3.2 为什么使用fileprivate?

在 Swift 中,fileprivate 是一个访问控制修饰符,它指定某个声明只能在其定义的文件内部访问。

我们的代码中,fileprivate 修饰 struct TabFinderextension NSView,意味着这两个声明只能在它们被定义的文件中访问,不能在文件的外部访问。这是一种封装的方式,确保了代码的模块化和安全性,防止了外部代码访问或修改这些内部实现。

3.3.3 Type.Type 新魔法?

在代码中,Type.Type 是指定泛型 Type 的元类型。

在 Swift 中,每个类型都有一个与之关联的元类型,用来表示类型本身,而不是类型的实例。

在这个 func subviews<Type: NSView>(type: Type.Type) -> [Type] 函数签名中,Type.Type 指定了我们想要查询的 NSView 子类的类型,而 Type 是泛型参数,代表了我们想要查询的具体 NSView 子类类型。

这个代码设计允许我们以类型安全的方式查询特定类型的子视图,而不需要对子视图的类型进行硬编码或强制类型转换。

4. 结尾

好了,本期的Demo 和 Code review 就结束了。我们下期再见。