UI 编程并不只是简单的控件堆叠,它非常考验开发者的 API 交互设计能力以及对整体结构的理解程度。
在 iOS 13.0 中,UIKit 引入了 UITableViewStyleInsetGrouped 样式,使 UITableView 轻松实现圆角卡片布局。在 SwiftUI 中,要实现类似的效果也相当简单,只需在 List 中使用 Section。但并不总是需要使用 List,因为在大多数情况下,我们更喜欢使用 ScrollView。然而,在 ScrollView 中使用 Section 时,表现有点奇怪,而且 Section 的自定义能力相对有限。因此,我们可以尝试自己创建一个类似的组件。当然,我们的目标并不是替代 Section,而是向您展示如何按照 SwiftUI 的设计原则创建一个自定义控件。
// 使用 List + Section
List {
Section {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 List + Section 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title")
}
}
// 使用 ScrollView + Card
ScrollView {
Card {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 ScrollView + Card 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title")
}
}
定义 Card View
首先,我们希望实现类似于 Section 的功能,并保持 API 设计相似。因此我们可以按照 Section 的设计思路定义 Card 的视图结构,该结构应包括 header、footer 和 content 部分:
public 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
public 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
public 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
public 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
public init(@ViewBuilder content: () -> Content) {
self.header = Parent()
self.content = content()
self.footer = Footer()
}
}
我们在不同的 Card 扩展中实现了针对不同场景的初始化方法,充分利用了 Swift 的泛型 Where 子句扩展功能。接下来还需要让 Card 遵循 View 协议,并提供 body 计算属性。
extension Card : View where Parent : View, Content : View, Footer : View {
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.horizontal)
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(.horizontal)
}
.frame(minWidth: 10, maxWidth: .infinity, alignment: .leading)
.padding()
}
}
写到这里,Card 的基本功能已经完成了,但事情远远没有结束。Card 目前还不具备自定义风格的能力,一旦外观风格发生变化,就需要不断修改 body 属性中的内容。如果同时存在多个外观风格,这将会变得非常混乱。幸运的是,Apple 已经为我们指明了方向:通过指定 Style 来改变视图的外观。
通过指定 Style 改变视图外观
许多读者可能已经接触过 Button 、Label 或 Picker 等组件。在使用它们时,我们可以分别指定不同的样式来改变视图外观。例如,对于 Button,开发者可以使用 buttonStyle(_:) 来选择使用哪种外观样式;而对于 Picker,可以使用pickerStyle(:_)。它们的原理实际上都是通过 Style 中的不同配置项将视图包装成一个单独的协议,然后提供多种不同的协议实现版本,最后可以指定使用某个具体实现来完成外观切换的功能。因此,我们也可以采用类似的方式实现 Card 的外观定制切换,就像这样:
ScrollView {
Card {
Text("Hello world!")
Text("Hello world!")
} header: {
Text("使用 ScrollView + Card 实现\n标题只能大写:hello world!")
} footer: {
Text("This is a Footer title 1")
}
}
.cardStyle(ColorfulRoundedCardStyle(.white, cornerRadius: 16))
CardStyle 协议
首先,我们需要定义 CardStyle 协议,该协议用于提供一些配置项以及需要改变外观的视图。Content 用于表示卡片中的内容部分,即 Card 的 content,在前文的示例中,它代表那两个 Text("Hello world!")。为了简洁起见,我在这里省略了对 header 和 footer 的定义,将重点放在 content 上。在 Content 中。由于Apple 没有公开具体的实现细节,因此我们可以根据自己的方式来实现它。
public struct CardStyleConfiguration {
public struct Content: View {
// 通过闭包为 content 提供视图
fileprivate let makeBody: () -> AnyView
var body: some View { makeBody() }
}
public let content: Content
// 这里省略了 header 和 footer 的定义,感兴趣的读者可以在阅读完本文后自行尝试实现。
// public let header: Header
// public let footer: Footer
}
public protocol CardStyle {
associatedtype Body : View
@ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = CardStyleConfiguration
}
接下来让我们实现一个默认的 DefaultCardStyle:
public struct DefaultCardStyle: CardStyle {
public func makeBody(configuration: Configuration) -> some View {
configuration.content
.padding()
.background(Color(uiColor: .quaternarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
一旦有了 CardStyle,我们还需要找到一种方法将其传递给视图树中的所有 Card 。在 SwiftUI 中,我们不能像在 UIKit 那样轻松地获取一个视图的所有子视图,因此将父级视图提供的值传递给子视图需要一些技巧。一个比较好的方案是使用 Environment。
使用 @Environment 传递 CardStyle
Environment是一种属性包装器,用于从视图的环境中读取值。使用属性包装器,可以读取存储在视图环境中的值。它将某个值从当前视图树的节点一路向下传递给每一个子视图节点。您可以在这篇文章中找到相关详细介绍。
自定义一个 Environment Key 来传递指定的 CardStyle,可以称其为 CardStyleEnvironmentKey。然后,提供一个 keyPath,以便可以读取存储在视图环境中的 CardStyle。接下来在 Card 中,通过 @Environment 来读取当前的 cardStyle,并修改 body 的实现。
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 }
}
}
public 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 {
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
.font(.system(size: 13))
.foregroundColor(Color.secondary)
.padding(.horizontal)
// 使用 cardStyle 创建新更改外观后的视图结构
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(.horizontal)
}
.frame(minWidth: 10, maxWidth: .infinity, alignment: .leading)
}
}
为 View 添加扩展
为了提高 API 的可读性并隐藏不必要的细节,可以像 Button 一样为 Card 提供一个名为 cardStyle(_:)的API来封装 environment(_:_:),这样我们就可以使用它来指定 Card 的外观了。
extension View {
public func cardStyle<S>(_ style: S) -> some View where S : CardStyle {
environment(\.cardStyle, style)
}
}
public struct ColorfulRoundedCardStyle: CardStyle {
public var color: Color = Color(uiColor: .quaternarySystemFill)
public var cornerRadius: CGFloat = 20
public 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(ColorfulRoundedCardStyle(color: .yellow))
最后
在 SwiftUI 时代,编写 UI 变得非常简单,但如何保持你的代码简洁、优雅和高效,这一点从未改变。