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(.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
等组件。在运用它们时,咱们能够分别指定不同的款式来改动视图外观。例如,关于 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(ColorfullRoundedCardStyle(.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(.leading)
// 运用 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(.leading)
}
.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 ColorfullRoundedCardStyle: 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(ColorfullRoundedCardStyle(color: .yellow))
最终
在 SwiftUI 年代,编写 UI 变得十分简略,但怎么坚持你的代码简练、优雅和高效,这一点从未改动。