用符合 SwiftUI 设计风格的方式写一个 Card View

1,959 阅读6分钟

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

image.png

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

image.png

定义 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()
    }
}

image.png

写到这里,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(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))

image.png

最后

在 SwiftUI 时代,编写 UI 变得非常简单,但如何保持你的代码简洁、优雅和高效,这一点从未改变。