Skip to content

第54天 项目 11 第二部分

今天,我们将开始应用你学到的新技术来构建我们的应用,使用SwiftData创建书籍,并使用自定义的RatingView组件(通过@Binding实现)让用户记录他们对每本书的喜爱程度。

数据处理方式对我们的工作至关重要。有时,这简单到只需确定哪些内容应设为整数、哪些应设为字符串;有时则需要一些理论知识,比如判断该使用数组还是集合;还有些时候,这意味着我们需要思考对象之间的关联方式。

我非常喜欢莱纳斯·托瓦兹(Linus Torvalds)的一句名言:“糟糕的程序员担心代码;优秀的程序员担心数据结构及其关联。”我喜欢这句话,一方面是因为它强调了设计良好数据结构的重要性,另一方面也提醒我们,一旦掌握了一种编程语言,转而学习其他语言就会相对容易——语法可能不同,但数据结构通常相同或极为相似。

今天你需要完成三个主题的学习,在这些主题中,你将运用新学到的SwiftData技能,结合List@Binding等知识进行实践。

  • 使用SwiftData创建书籍
  • 添加自定义星级评分组件
  • 利用@Query构建列表

别忘了和他人分享你的进展——你现在正在构建自己的SwiftData模型和SwiftUI组件,这有助于你保持学习的责任感。

使用SwiftData创建书籍

作者:Paul Hudson 2024年8月9日

我们在这个项目中的首要任务是为书籍设计一个SwiftData模型,然后创建一个新视图用于向数据库中添加书籍。

首先是模型创建:新建一个名为Book.swift的文件,导入SwiftData,然后添加以下代码:

swift
@Model
class Book {
    var title: String
    var author: String
    var genre: String
    var review: String
    var rating: Int
}

这个类需要一个初始化器来为所有属性赋值,不过有个小技巧:在类内部只需输入“in”,Xcode就应该会自动补全整个初始化器。

这个类足以存储书籍的标题、作者姓名、类型、用户对书籍的简要评论,以及用户对书籍的数字评分。

现在我们有了数据模型,接下来可以让SwiftData为其创建一个模型容器。具体操作是打开BookwormApp.swift文件,在文件顶部添加import SwiftData,然后为WindowGroup添加以下修饰符:

swift
.modelContainer(for: Book.self)

下一步是编写一个用于创建新条目的表单。这将综合运用你到目前为止学到的许多技能:Form@State@EnvironmentTextFieldTextEditorPickersheet()等等,再加上所有新学的SwiftData知识。

首先创建一个名为“AddBookView”的新SwiftUI视图。在属性方面,我们需要一个环境属性来获取模型上下文:

swift
@Environment(\.modelContext) var modelContext

由于这个表单要存储构成一本书所需的所有数据,我们需要为书籍的每个属性创建@State属性。因此,接下来添加这些属性:

swift
@State private var title = ""
@State private var author = ""
@State private var rating = 3
@State private var genre = "Fantasy"(奇幻)
@State private var review = ""

最后,我们还需要一个属性来存储所有可能的书籍类型选项,以便使用ForEach创建选择器。现在将这个属性添加到AddBookView中:

swift
let genres = ["Fantasy(奇幻)", "Horror(恐怖)", "Kids(儿童)", "Mystery(悬疑)", "Poetry(诗歌)", "Romance(浪漫)", "Thriller(惊悚)"]

现在我们可以先初步构建表单——之后会对其进行完善,但目前这样就足够了。将当前的body替换为以下内容:

swift
NavigationStack {
    Form {
        Section {
            TextField("书籍名称", text: $title)
            TextField("作者姓名", text: $author)

            Picker("类型", selection: $genre) {
                ForEach(genres, id: \.self) {
                    Text($0)
                }
            }
        }

        Section("撰写评论") {                
            TextEditor(text: $review)

            Picker("评分", selection: $rating) {
                ForEach(0..<6) {
                    Text(String($0))
                }
            }                
        }

        Section {
            Button("保存") {
                // 添加书籍
            }
        }
    }
    .navigationTitle("添加书籍")
}

至于按钮动作的实现,我们需要使用表单中的所有值创建一个Book类的实例,然后将该对象插入到模型上下文中。

// 添加书籍注释替换为以下代码:

swift
let newBook = Book(title: title, author: author, genre: genre, review: review, rating: rating)
modelContext.insert(newBook)

至此,表单的初步构建已完成,但我们还需要一种方法来控制添加书籍时表单的显示和隐藏。

要显示AddBookView,需要回到ContentView.swift,并按照显示工作表(sheet)的常规步骤操作:

  1. 添加一个@State属性来跟踪工作表是否显示。
  2. 添加一个按钮(此处选择在工具栏中添加)来切换该属性的状态。
  3. 添加一个sheet()修饰符,当属性变为true时显示AddBookView

闲话少说,让我们继续编写代码。首先在ContentView.swift中导入SwiftData,然后为ContentView结构体添加这些属性:

swift
@Environment(\.modelContext) var modelContext
@Query var books: [Book]

@State private var showingAddScreen = false

这样我们就有了一个后续可用于删除书籍的模型上下文、一个读取所有现有书籍的查询(以便验证一切是否正常工作),以及一个跟踪添加界面是否显示的布尔值。

对于ContentViewbody,我们将使用导航栈(navigation stack),以便添加标题和右上角的按钮,除此之外,界面中还会显示一个文本,用于展示books数组中的书籍数量——这样我们就能确认所有功能是否正常运行。记住,这里需要添加sheet()修饰符,以便在需要时显示AddBookView

ContentView现有的body属性替换为以下内容:

swift
 NavigationStack {
    Text("数量:\(books.count)")
        .navigationTitle("Bookworm(书虫)")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button("添加书籍", systemImage: "plus") {
                    showingAddScreen.toggle()
                }
            }
        }
        .sheet(isPresented: $showingAddScreen) {
            AddBookView()
        }
}

提示: 这里明确指定了按钮在右上角的位置,以便之后添加第二个按钮。

稍等片刻——我们就快完成了!到目前为止,我们已经设计了SwiftData模型、创建了用于添加数据的表单,还更新了ContentView,使其能在需要时展示该表单。最后一步是让用户添加书籍后,表单能自动消失。

我们之前做过类似的操作,所以希望你清楚步骤。首先需要在AddBookView中添加另一个环境属性,用于关闭当前视图:

swift
@Environment(\.dismiss) var dismiss

最后,在“保存”按钮的动作闭包末尾添加dismiss()调用。

现在运行应用,应该可以顺利添加示例书籍了。当AddBookView滑出屏幕后,数量标签应更新为1。

添加自定义星级评分组件

作者:Paul Hudson 2024年4月11日

SwiftUI让创建自定义UI组件变得非常简单,因为这些组件本质上就是一些暴露了@Binding属性供我们读取的视图。

为了演示这一点,我们将构建一个星级评分视图,用户可以通过点击图像来输入1到5之间的分数。虽然我们可以将这个视图设计得仅适用于当前的特定场景,但在合适的情况下增加一些灵活性,使其能在其他地方复用,通常是更好的选择。在这里,我们将为其设置六个可自定义的属性:

  • 评分前应显示的标签(默认:空字符串)
  • 最大整数评分(默认:5)
  • 未选中和选中状态的图像,用于指定星级高亮或未高亮时显示的图像(默认:未选中图像为nil,选中图像为实心星;如果未选中图像为nil,则未选中状态也使用选中图像)
  • 未选中和选中状态的颜色,用于指定星级高亮或未高亮时显示的颜色(默认:未选中为灰色,选中为黄色)

我们还需要一个额外的属性来存储@Binding类型的整数,以便将用户的选择反馈给使用这个星级评分组件的对象。

因此,创建一个名为“RatingView”的新SwiftUI视图,并首先为其添加这些属性:

swift
@Binding var rating: Int

var label = ""

var maximumRating = 5

var offImage: Image?
var onImage = Image(systemName: "star.fill")

var offColor = Color.gray
var onColor = Color.yellow

在填充body属性之前,请尝试编译代码——你会发现编译失败,因为#Preview代码没有传入用于rating的绑定值。

SwiftUI为此提供了一个专门且简单的解决方案,称为“常量绑定”(constant bindings)。这种绑定具有固定的值,一方面意味着它们无法在UI中被修改,另一方面也意味着我们可以轻松创建它们——非常适合用于预览。

因此,将现有的预览代码替换为以下内容:

swift
#Preview {
    RatingView(rating: .constant(4))
}

现在我们来编写body属性。它将是一个HStack,包含(如果提供了的话)标签,以及根据需求显示的若干个星级——当然,用户也可以选择其他任何图像,所以显示的不一定是星星。

选择显示哪个图像的逻辑相当简单,但将其提取到单独的方法中,可以降低代码的复杂度,这是个不错的做法。逻辑如下:

  • 如果传入的数字大于当前评分,且已设置未选中图像,则返回未选中图像;否则返回选中图像。
  • 如果传入的数字小于或等于当前评分,则返回选中图像。

我们可以将这个逻辑封装在一个方法中,现在就将这个方法添加到RatingView中:

swift
func image(for number: Int) -> Image {
    if number > rating {
        offImage ?? onImage
    } else {
        onImage
    }
}

现在实现body属性就非常简单了:如果标签有文本内容,就显示标签;然后使用ForEach从1到最大评分值依次计数,并重复调用image(for:)方法。我们还会根据评分设置前景色,并将每个星级包裹在一个按钮中,通过点击按钮来调整评分。

将现有的body属性替换为以下内容:

swift
HStack {
    if label.isEmpty == false {
        Text(label)
    }

    ForEach(1..<maximumRating + 1, id: \.self) { number in
        Button {
            rating = number
        } label: {
            image(for: number)
                .foregroundStyle(number > rating ? offColor : onColor)
        }
    }
}

至此,我们的评分视图已构建完成。要使用它,只需回到AddBookView,将第二个分区替换为以下内容:

swift
Section("撰写评论") {
    TextEditor(text: $review)
    RatingView(rating: $rating)
}

我们设置的默认值很合理,所以这个组件在默认情况下看起来效果很好——现在就可以尝试使用了!

不过你可能会发现,事情并没有完全按预期工作:无论点击哪个星级,最终都会选中5颗星!

我见过无数人遇到过这个问题,无论他们经验多丰富。问题在于,当表单或列表中有行时,SwiftUI通常会默认认为这些行本身是可点击的。这能让用户操作更便捷,因为他们可以点击行中的任意位置来触发行内的按钮。

但在我们的案例中,行内有多个按钮,所以SwiftUI会依次触发所有按钮——rating会先被设为1,然后是2、3、4,最后是5,这就是为什么无论点击哪个星级,最终评分都是5的原因。

我们可以给整个HStack添加一个额外的修饰符,来禁用“点击行触发行内按钮”的这种默认行为:

swift
.buttonStyle(.plain)

这样SwiftUI就会将每个按钮视为独立的个体,一切就能正常工作了。而且这样的交互体验更好:用户无需点击进入详情视图再使用选择器,因为星级评分更加直观和常用。

利用@Query构建列表

作者:Paul Hudson 2023年11月18日

目前,我们的ContentView中有一个这样的查询属性:

swift
@Query var books: [Book]

并且我们在body中通过以下简单的文本视图来使用它:

swift
Text("数量:\(books.count)")

要让这个界面更生动,我们将把这个文本视图替换为一个List,用于显示所有已添加的书籍,以及每本书的评分和作者。

我们可以直接使用之前制作的星级评分视图,但尝试一些新东西会更有趣。之前的RatingView控件可用于各种项目,而我们可以为当前项目创建一个新的EmojiRatingView,用于显示特定的评分样式。这个视图的功能很简单,就是根据评分显示五种不同表情符号中的一种。这是一个很好的例子,展示了在SwiftUI中组合视图是多么简单——将视图的一小部分单独提取出来,操作起来非常容易。

因此,创建一个名为“EmojiRatingView”的新SwiftUI视图,并添加以下代码:

swift
struct EmojiRatingView: View {
    let rating: Int

    var body: some View {
        switch rating {
        case 1:
            Text("1")
        case 2:
            Text("2")
        case 3:
            Text("3")
        case 4:
            Text("4")
        default:
            Text("5")
        }
    }
}

#Preview {
    EmojiRatingView(rating: 3)
}

提示: 我在文本中使用了数字,因为表情符号可能会对电子阅读器造成显示问题,但你可以将这些数字替换为任何你认为适合表示不同评分的表情符号。

现在我们可以回到ContentView,初步构建它的UI。我们将用一个列表和遍历booksForEach来替换现有的文本视图。由于所有SwiftData模型都自动遵循Identifiable协议,因此我们不需要为ForEach提供标识符。

在列表中,我们将添加一个指向当前书籍的NavigationLink,在这个链接内部,我们会放置新创建的EmojiRatingView,以及书籍的标题和作者。因此,将现有的文本视图替换为以下内容:

swift
List {
    ForEach(books) { book in
        NavigationLink(value: book) {
            HStack {
                EmojiRatingView(rating: book.rating)
                    .font(.largeTitle)

                VStack(alignment: .leading) {
                    Text(book.title)
                        .font(.headline)
                    Text(book.author)
                        .foregroundStyle(.secondary)
                }
            }
        }
    }
}

提示: 确保保留之前的修饰符,如navigationTitle()等。

目前导航功能还无法正常工作,因为我们尚未添加navigationDestination(),不过没关系——我们很快就会回到这个界面。首先,让我们来构建详情视图……

本站使用 VitePress 制作