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")
  }
}

用契合 SwiftUI 规划风格的方法写一个 Card View

// 运用 ScrollView + Card
ScrollView {
  Card {
    Text("Hello world!")
    Text("Hello world!")
  } header: {
    Text("运用 ScrollView + Card 完成n标题只能大写:hello world!")
  } footer: {
    Text("This is a Footer title")
  }
}

用契合 SwiftUI 规划风格的方法写一个 Card View

界说 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()
    }
}

用契合 SwiftUI 规划风格的方法写一个 Card View

写到这儿,Card 的基本功用现已完成了,但事情远远没有结束。Card 现在还不具有自界说风格的能力,一旦外观风格发生变化,就需求不断修改 body 特点中的内容。如果同时存在多个外观风格,这将会变得十分紊乱。走运的是,Apple 现已为咱们指明了方向:经过指定 Style 来改动视图的外观。

经过指定 Style 改动视图外观

许多读者可能现已触摸过 ButtonLabelPicker 等组件。在运用它们时,咱们能够分别指定不同的款式来改动视图外观。例如,关于 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 规划风格的方法写一个 Card View

最终

在 SwiftUI 年代,编写 UI 变得十分简略,但怎么坚持你的代码简练、优雅和高效,这一点从未改动。