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

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 改变视图外观

相信大部分读者应该已经接触过 ButtonLabel或者 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))
相关推荐
小溪彼岸2 天前
【iOS小组件】小组件尺寸及类型适配
swiftui·swift
文件夹__iOS7 天前
[SwiftUI 开发] @dynamicCallable 与 callAsFunction:将类型实例作为函数调用
ios·swiftui·swift
小溪彼岸7 天前
【iOS小组件】iOS17与低版本兼容适配
swiftui·swift
Mamong8 天前
SwiftUI疑难杂症(1):sheet content多次执行
ios·swiftui·swift
AUV110712 天前
Mac剪贴板历史全记录!
macos·swiftui·mac·效率工具·实用工具·剪贴板·clipboard
AUV110712 天前
Mac 上哪个剪切板增强工具比较好用? 好用剪切板工具推荐
macos·swiftui·mac·剪贴板·clipboard·剪贴板增强·app 推荐
多彩电脑14 天前
SwiftUI里的ForEach使用的注意事项
macos·ios·swiftui·swift
Swift社区16 天前
Apple 新品发布会亮点有哪些 | Swift 周报 issue 61
ios·swiftui·swift
humiaor17 天前
Xcode报错:No exact matches in reference to static method ‘buildExpression‘
swiftui·xcode
humiaor25 天前
Xcode报错:Return from initializer without initializing all stored properties
swiftui·binding