用符合 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))
相关推荐
小洋人最happy4 天前
SwiftUI基础组件之HStack、VStack、ZStack详解
swiftui·vstack·zstack·hstack·spacing
coooliang4 天前
【iOS】SwiftUI状态管理
ios·swiftui·swift
小洋人最happy4 天前
SwiftUI基础组件之List详解
list·swiftui·selection·列表组件·ondelete
struggle20256 天前
Ollmao (OH-luh-毛程序包及源码) 是一款原生 SwiftUI 应用程序,它与 Ollama 集成,可在 Mac 上本地运行强大的 AI 模型
ios·swiftui·swift
货拉拉技术1 个月前
货拉拉用户端SwiftUI踩坑之旅
ios·swiftui·swift
ZacJi1 个月前
巧用 allowsHitTesting 自定义 SignInWithAppleButton
ios·swiftui·swift
刘争Stanley2 个月前
SwiftUI 是如何改变 iOS 开发游戏规则的?
ios·swiftui·swift
1024小神2 个月前
在swiftui中使用Alamofire发送请求获取github仓库里的txt文件内容并解析
ios·github·swiftui
大熊猫侯佩2 个月前
SwiftUI 撸码常见错误 2 例漫谈
swiftui·xcode·tag·tabview·preview·coredata·fetchrequest
东坡肘子2 个月前
肘子的 Swift 周报 #063|异种肾脏移植取得突破
swiftui·swift·apple