UI 编程并不只是简单的控件堆叠,它非常考验开发者的 API 交互设计能力以及对整体结构的理解程度。
UIKit 在 iOS 13.0 中为我们带来了 UITableViewStyleInsetGrouped
样式,它能为我们的 tableView 轻松提供圆角卡片的布局方式。在 SwiftUI 中实现这点也相当容易,只需要在 List 中使用 Section 即可。但是我们并不一定总是需要使用 List,大多数情况下我更愿意使用 ScrollView 来替代。然而在 ScrollView 中 Section 的表现却略显奇怪,并且 Section 的可定制程度真的不高,所以我们可以尝试自己来实现一个类似的组件。当然我们的目的不是为了替换 Section,而是借此跟大家讲一讲如何使用符合 SwiftUI 设计风格的方式来制作一个自定义控件。
swift
// 使用 List + Section
List {
Section {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 List + Section 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title")
}
}

swift
// 使用 ScrollView + Card
ScrollView {
Card {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 ScrollView + Card 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title 1")
}
}

定义 Card View
首先我们希望能实现类似 Section 的功能,并保持相似的 API 设计。所以我们可以仿照它定义 Card
的视图结构,它应当包含 header,footer 以及 content 部分:
swift
struct Card<Parent, Content, Footer> {
private let header: Parent
private let content: Content
private let footer: Footer
}
extension Card where Parent : View, Content : View, Footer : View {
/// 同时包含 header,footer 和 content
init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Parent, @ViewBuilder footer: () -> Footer) {
self.header = header()
self.content = content()
self.footer = footer()
}
}
extension Card where Parent == EmptyView, Content : View, Footer : View {
/// 只包含 content 和 footer
init(@ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer) {
self.header = Parent()
self.content = content()
self.footer = footer()
}
}
extension Card where Parent : View, Content : View, Footer == EmptyView {
/// 只包含 content 和 header
init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Parent) {
self.header = header()
self.content = content()
self.footer = Footer()
}
}
extension Card where Parent == EmptyView, Content : View, Footer == EmptyView {
/// 只包含 content
init(@ViewBuilder content: () -> Content) {
self.header = Parent()
self.content = content()
self.footer = Footer()
}
}
我们在 Card 的不同扩展中实现了针对不同场景的初始化方法,这点充分利用了 Swift 具有泛型 Where 子句扩展的特性。接下来我们需要让 Card 遵循 View
协议提供 body
计算属性。
swift
extension Card : View where Parent : View, Content : View, Footer : View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.leading)
VStack(alignment: .leading, spacing: 16) {
// 这里用 VStack 包裹一层的目的是让 content 中的所有内容作为一个整体
content
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
footer
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.leading)
}
.frame(minWidth: 10, maxWidth: .infinity, alignment: .leading)
.padding()
}
}

写到这里,Card 的基本功能就算完成了。但,事情还远没有结束 ,我们需要做的更多。Card 目前还不具备自定义风格的能力,一旦有风格的变化,就需要不停的去修改 body
属性中的内容,一旦同时存在多个风格外观,这将是一场灾难。幸运的是,Apple 已经为我们指明了方向:通过指定 Style 改变视图外观。
通过指定 Style 改变视图外观
相信大部分读者应该已经接触过 Button
、Label
或者 Picker
了。在使用它们时,我们可以分别指定不同的 style 来改变视图外观。对于 Button 来说,开发者可以使用 buttonStyle(_:)
来选择使用哪种风格的外观,对于 Picker 来说则是使用 pickerStyle(:_)
。它们的原理其实都是通过 Style
中的不同配置项将视图包装后独立成一个单独的协议,再分别提供协议的多种实现版本,最后指定某个具体实现即可完成外观切换的功能。因此,我们也可以采用类似的方式实现 Card
外观的定制切换,就像这样:
swift
ScrollView {
Card {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 ScrollView + Card 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title 1")
}
}
.cardStyle(ColorfullRoundedCardStyle(.white, cornerRadius: 16))
然而 Apple 并没有为我们提供任何现成的方案,我们需要重头开始。
CardStyle 协议
首先,我们需要定义 CardStyle
协议,用来提供一些配置项以及需要改变外观的视图。
swift
struct CardStyleConfiguration {
struct Content: View {
fileprivate let makeBody: () -> AnyView
var body: some View { makeBody() }
}
let content: Content
// 这里省略了 header 和 footer 的定义,感兴趣的读者可以在阅读完本文后自行尝试实现。
// let header: Header
// let footer: Footer
}
protocol CardStyle {
associatedtype Body : View
@ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = CardStyleConfiguration
}
Contnet
用来表示卡片中的内容部分,即 Card 的 content,在上文的例子中就是那两个 Text("Hello world!")
。为了节约篇幅,我这里省略了对 header 和 footer 的定义,将重点放置在 content 上。Content
中的 makeBody
则是为了给 content 提供视图。Apple 对这一块没有公开任何细节,所以我们可以按照自己的方式去实现。
接下来让我们实现一个默认的 DefaultCardStyle
:
swift
struct DefaultCardStyle: CardStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.content
.padding()
.background(Color(uiColor: .quaternarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
有了 CardStyle 后还需要想个办法把它传递给视图树中的所有 Card。在 SwiftUI 中我们不能像 UIKit 那样获取一个 view 的所有 child,因此将父级视图提供的值传递给子视图需要一些技巧。一个比较好的方案是使用 Environment
。
使用 @Environment 传递 CardStyle
Environment 是从一个视图的环境中读取值的属性包装器,使用属性包装器读取存储在视图环境中的值。它将某个值从当前视图树的节点一路向下传递给每一个子视图节点。相关详细介绍可参考这篇文章。
我们可以自定义一个 Environment Key 用来传递我们指定的 CardStyle,就叫它 CardStyleEnvironmentKey
好了。我们还需要提供一个 keyPath 方便我们读取存储在视图环境中的 CardStyle,接着在 Card 中通过 @Environment 读取当前的 cardStyle,并修改 body 的实现:
swift
private struct CardStyleEnvironmentKey: EnvironmentKey {
static var defaultValue: any CardStyle { DefaultCardStyle() }
}
extension EnvironmentValues {
fileprivate var cardStyle: any CardStyle {
get { self[CardStyleEnvironmentKey.self] }
set { self[CardStyleEnvironmentKey.self] = newValue }
}
}
struct Card<Parent, Content, Footer> {
// 读取当前的 cardStyle
@Environment(\.cardStyle) private var cardStyle:
private let header: Parent
private let content: Content
private let footer: Footer
}
extension Card : View where Parent : View, Content : View, Footer : View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.leading)
let styledView = cardStyle.makeBody(
configuration: CardStyleConfiguration(
content: CardStyleConfiguration.Content(
makeBody: {
AnyView(
// 这里用 VStack 包裹一层的目的是让 content 中的所有内容作为一个整体
VStack(spacing: 16) {
content.frame(maxWidth: .infinity, alignment: .leading)
}
)
}
)
)
)
AnyView(styledView)
footer
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.leading)
}
.frame(minWidth: 10, maxWidth: .infinity, alignment: .leading)
}
}
为 View 添加扩展
为了使 API 更具可读性并对外隐藏不必公开的细节,我们可以像 Button 一样为 Card 提供名为 cardStyle(_:)
的 API 对 environment(_:_:)
进行包装,如此,我们便可以使用它 为 Card 指定外观了。
swift
extension View {
func cardStyle<S>(_ style: S) -> some View where S : CardStyle {
environment(\.cardStyle, style)
}
}
struct ColorfullRoundedCardStyle: CardStyle {
var color: Color = Color(uiColor: .quaternarySystemFill)
var cornerRadius: CGFloat = 20
func makeBody(configuration: Configuration) -> some View {
configuration.content
.padding()
.background(color)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
ScrollView {
Card {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 ScrollView + Card 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title 1")
}
.padding(.horizontal)
}
.cardStyle(ColorfullRoundedCardStyle(color: .yellow))
