第13章:概述一个照片拼贴应用程序¶
祝贺你--你已经写出了你的第一个应用程序!你知道吗?HIITFit
使用了标准的iOS
用户交互,包括列表和可滑动的页面视图。现在,你将用自定义手势和自定义视图来编写更复杂的东西。
照片拼贴应用程序非常流行,你将建立你自己的拼贴应用程序来创建卡片来分享。你将能够添加图片,从你的照片或从互联网上,并添加文本和贴纸。这个应用程序将是真实世界的,与真实世界的问题相匹配。
在这一章中,你将看一下应用程序想法的草图大纲,并创建一个视图层次,这将是你的应用程序的骨架。
在第2节结束时,你完成的应用程序将看起来像这样:
初始的应用程序想法¶
创建一个新的应用程序的第一步是有想法。在编写任何代码之前,你应该研究你的应用程序是否会成为一个热门或失败。弄清楚你的目标受众是谁,并与一些可能使用你的应用程序的人交谈。了解你在App Store
中的竞争情况,探讨你的应用程序如何提供新的和不同的东西。
一旦你决定你有一个成功的机会,就把你的应用程序画出来,并找出可行性和可能存在的技术困难。
你的照片拼贴应用程序将有一个主视图--在那里你列出所有的卡片--和一个选定卡片的详细视图--在那里你可以添加照片和文字。这可能是草图的背面。
在接下来的章节中,你将设置数据模型和数据存储,但现在,请检查设计并思考可能需要克服的实施困难。始终采取模块化的方法,尽可能将应用程序的每个方面与主应用程序分开测试。
SwiftUI
在这方面做得很好,因为你可以使用SwiftUI
的实时预览功能独立构建视图和控件。当你对一个视图的工作方式感到满意时,就把它添加到你的应用程序中。
创建项目¶
在上一节中,你从一个包含创建HIITFit
所需的所有资产的启动程序开始。在本节中,你将从一个新的应用程序开始,在接下来的几章中,你会发现如何添加资产。
➤ 打开Xcode
并选择File ▸ New ▸ Project
并使用iOS App
模板创建一个名为Cards
的新项目。如果你需要复习一下如何创建一个新的SwiftUI
项目,你可以在第1章"检查你的工具"中找到所有信息。
➤ 单击运行目的地按钮,选择iPhone 12 Pro
。使用Command-R
构建并运行你的应用程序,以确保一切工作正常。你的iPhone 12 Pro
模拟器应该启动并显示ContentView
的Hello, world!
文本。
每次创建一个新的应用程序时,你都应该采取这些步骤,以防你的环境发生了变化。
为你的项目创建第一个视图¶
你将在本节中学习的技能:ScrollView
➤ 创建一个名为CardsListView.swift
的SwiftUI
视图文件。
此视图将显示您在应用程序中创建的所有卡片的滚动缩略图列表。
创建一个卡片的列表¶
➤ 打开CardsListView.swift
。目前,你将显示一个圆角矩形的占位符列表,而不是卡片。
➤ 用以下语句替换body
:
var body: some View {
ScrollView {
VStack {
ForEach(0..<10) { _ in
RoundedRectangle(cornerRadius: 15)
.foregroundColor(.gray)
.frame(width: 150, height: 250)
}
}
}
}
这将在一个可滚动的VStack
中放置十个形状。
一个ScrollView
可以是垂直的或水平的。这里使用的默认值是垂直的,但你可以用ScrollView(.horizontal)
指定一个水平轴。
➤ 实时预览该视图,就可以滚动列表了。当你滚动时,你可以看到卡片边上有一个丑陋的滚动条。
➤ 如果看不到画布,可以用Xcode
右上方的图标来启用它:
➤ 将ScrollView {
改为:
ScrollView(showsIndicators: false) {
这将关闭滚动条。
重构视图¶
本节中你将学习的技能:重构视图;环境对象中的视图状态
当你添加视图时,你会认识到以后有些视图会变得更加复杂。RoundedRectangle
就是这样一个视图。你已经给了它基本的样式,但你可能还想进一步给它样式。在早期重构视图要容易得多,所以你现在要为占位卡创建一个新视图。你在第三章"主视图的原型设计"中提取了一个视图,所以这对你来说应该是一个复习。
➤ 创建一个新的SwiftUI
视图文件,名为CardThumbnailView.swift
。
➤ 回到CardsListView.swift
中,命令单击RoundedRectangle
并选择提取子视图。
➤ 将ExtractedView
重命名为CardThumbnailView
。
现在,提取的视图位于当前文件的末尾,看起来像这样。
struct CardThumbnailView: View {
var body: some View {
RoundedRectangle(cornerRadius: 15)
.foregroundColor(.gray)
.frame(width: 150, height: 250)
}
}
➤ 剪下这段代码,打开CardThumbnailView.swift
。
➤ 选择整个CardThumbnailView
结构,并粘贴剪切后的代码。
➤ 打开CardsListView.swift
。你的列表看起来还是一样的,但你以后给缩略图添加颜色和阴影会更容易。
设置单卡视图¶
➤ 创建一个名为SingleCardView.swift
的SwiftUI
视图文件。
➤ 一个卡片将有一个彩色的背景,你将在上面添加照片、贴纸和文本。
➤ 将body
替换为:
var body: some View {
Color.yellow
}
这种颜色最终将来自卡片数据,但目前你只需让卡片变成黄色。
从列表过渡到卡片¶
当你在CardsListView
的滚动列表中点击一张卡片时,你想显示SingleCardView
。你可以通过几种方式实现。
- 一个模式化的视图。
SingleCardView
会有一排链接到模态视图的按钮,在模态视图中设置模态视图不是很好的做法。 - 一个
NavigationView
有一个NavigationLink
目标,将SingleCardView
推到前面。这可能是一个很好的选择,但目前你不能在NavigationView
内自定义动画过渡,所以它减少了你的应用风格化的机会。 - 在视图层次中替换
CardsListView
。有了这个选项,当你在编辑卡片后回到缩略图时,你会在卡片列表中失去当前滚动的位置。 - 将
SingleCardView
放在CardsListView
前面的一个新层。这是你要选择的选项,这样你就可以在以后实验过渡。
在HIITFit
中,你使用了一个状态属性切换来显示历史视图,并在显示模版表时传递了一个绑定。Cards
中的导航将分布在多个文件中,所以用一个视图状态环境对象来集中该属性会更容易,该对象将在整个应用中共享。
创建一个环境对象¶
➤ 创建一个名为ViewState.swift
的新Swift
文件,并将代码替换为。
import SwiftUI
class ViewState: ObservableObject {
@Published var showAllCards = true
}
showAllCards
将控制SingleCardView
的显示,当你点击一个卡片时,你将改变它。你把它公布出来,这样使用showAllCards
的视图就会在数值改变时做出反应。
你应该记得第7章"观察对象",ObservableObject
是一个发布者,而ViewState
必须是一个类来符合它。
➤ 在CardsListView.swift
中,添加一个新的属性。
@EnvironmentObject var viewState: ViewState
你将在需要检查视图状态的地方添加环境对象。
➤ 在previews
中,将CardsListView()
改为:
CardsListView()
.environmentObject(ViewState())
这将为预览实例化环境对象。如果你不这样做,当你的代码试图访问viewState
时,画布就会崩溃,而不会出现特定的错误。
➤ 现在,你已经设置了环境,为CardThumbnailView()
添加一个修改器:
.onTapGesture {
viewState.showAllCards.toggle()
}
这将在你点击一个卡片缩略图时切换布尔值。当showAllCards
为false
时,你将在卡片列表的前面显示所选的单张卡片。
➤ 创建一个新的SwiftUI
视图文件,名为CardsView.swift
。
这个视图将是控制当前显示哪些全屏视图的初始视图。
➤ 将代码替换为:
import SwiftUI
struct CardsView: View {
@EnvironmentObject var viewState: ViewState
var body: some View {
ZStack {
CardsListView()
}
}
}
struct CardsView_Previews: PreviewProvider {
static var previews: some View {
CardsView()
.environmentObject(ViewState())
}
}
这里显示了CardsListView
并设置了环境对象viewState
。
➤ 在CardsListView()
之后,添加这个:
if !viewState.showAllCards {
SingleCardView()
}
你的ZStack
包含CardsListView
,在它前面是SingleCardView
,只有当你点了一个缩略图来触发布尔状态变化时才会显示。
➤ 实时预览并点击一张卡片。黄色的SingleCardView
就会显示在卡片列表的顶部。
要从SingleCardView
返回到缩略图列表,你需要创建一个Done
按钮。
在处理这个按钮之前,先将你的应用程序设置为在模拟器中运行,这样,如果实时预览失败,你仍然可以看到你的应用程序。
➤ 打开CardsApp.swift
,将ViewState
初始化为一个状态对象:
@StateObject var viewState = ViewState()
你这样做是为了让viewState
在你的应用程序中一直存在。如果你只是简单地把它初始化为CardsApp
中的环境对象,偶尔应用程序会重新初始化它,而且,如果你正在编辑卡片,你会神秘地回到第一个屏幕。
➤ 将ContentView()
改为:
CardsView()
.environmentObject(viewState)
你调用将显示卡片列表的视图,而不是ContentView
,确保你把viewState
放入应用环境中。
➤ 构建并运行,确保你的应用程序在模拟器中工作,就像在预览中一样。
你不再使用ContentView.swift
了,但你可以把它留在项目中,以试验其他SwiftUI
布局。
Navigation toolbar
¶
本节将学习的技能:工具条;NavigationView
;导航条;tuples
SingleCardView
中的Done
按钮将在viewState
中切换showAllCards
。你可以使用导航工具栏在屏幕的顶部和底部设置按钮。
➤ 打开SingleCardView.swift
,将环境对象添加到SingleCardView
中:
@EnvironmentObject var viewState: ViewState
➤ 记得更新previews
:
SingleCardView()
.environmentObject(ViewState())
➤ 为Color.yellow
添加一个新的工具栏修改器:
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { viewState.showAllCards.toggle() }) {
Text("Done")
}
}
}
你在屏幕的右上方放置一个Done
按钮。当用户点击这个按钮时,SingleCardView
会切换到showAllCards
。因为这是一个发布的属性,任何需要对showAllCards
做出反应的视图都会这样做,而CardsView
将不再显示SingleCardView
。
toolbar(content:)
允许多个ToolbarItem
。placement
可以是:
navigationBarLeading
: 顶部导航栏的前缘。navigationBarTrailing
: 顶部导航栏的尾部边缘。principal
: 在iOS上,主要位置是在导航条的中心。bottomBar
: 底部工具条。
你很快就会用到底部工具条的位置。
➤ 预览 SingleCardView
。
Note
这个按钮没有显示出来。这是因为ToolbarItem(place:)
使用了navigationBarTrailing
,所以任何项目只有在视图处于NavigationView
内时才会显示。
NavigationView
¶
➤ 在SingleCardView
中,命令点击Color
并选择Embed...
➤ 将占位符Container
改为NavigationView
。
➤ 恢复预览,你的按钮将显示出来。
添加一个导航栏¶
当你使用List
时,你经常会同时使用NavigationView
和NavigationLink
,它们有内置的推送和弹出过渡和标题。你将在第3节中更多地探讨这个问题。目前,你正在使用NavigationView
,不是为了转换,而是为了使SingleCardView
的工具栏的navigationBarTrailing
位置显示出完成按钮。使用NavigationView
意味着你可以利用导航条样式来设计屏幕的顶部。
你要给Color.yellow
添加另一个修改器,所以现在是时候趁机把它重构为一个单独的视图了。
➤ 命令单击 Color
并选择提取子视图。
➤ 将提取的视图命名为CardDetailView
。
➤ 创建一个新的SwiftUI
视图文件,名为CardDetailView.swift
,并将提取的CardDetailView
结构剪切并粘贴到CardDetailView.swift
中,替换掉模板CardDetailView
。
你会得到一个编译错误,因为缺少viewState
。
➤ 添加环境对象到CardDetailView
:
@EnvironmentObject var viewState: ViewState
➤ 像往常一样,更新previews
,以实例化环境对象:
CardDetailView()
.environmentObject(ViewState())
你的代码现在应该可以编译了。
回到SingleCardView.swift
中,你的代码看起来简单多了:
var body: some View {
NavigationView {
CardDetailView()
}
}
➤ 为CardDetailView
添加一个新的修改器:
.navigationBarTitleDisplayMode(.inline)
这设置了导航栏的样式。
其他风格包括automatic
和large
。如果你想给视图一个标题,你可以使用.navigationTitle("标题在这里")
。
➤ 预览 SingleCardView
以查看风格化的导航栏。
➤ 在纵向的iPad
模拟器上建立和运行。可以按Command-Right
箭头和Command-Left
箭头来旋转模拟器,或者选择Device ▸ Orientation
并选择所需的方向。
➤ 点一个卡片。
你会看到,你会得到一个白色的屏幕,上面有一个返回按钮。当你点击返回时,你会看到SingleCardView
的一部分和Done
按钮。点击Done
会让你回到第一个屏幕。这是由于NavigationView
在不同大小的配置下表现不同。
在第16章"向你的应用程序添加资产"中,你会了解到不同的设备有不同的尺寸类别。除了iPad
模拟器,你还可以在横向的iPhone 12 Pro Max
模拟器上复制,因为这也有一个较大的尺寸类。
➤ 在SingleCardView
中,给NavigationView
添加一个修改器:
.navigationViewStyle(StackNavigationViewStyle())
这种导航视图样式可以确保每次只看到一个顶视图。
➤ 构建并运行,现在您的应用程序的iPad
版本的行为方式将与iPhone 12 Pro
版本相同。
Note
当你做你自己的自定义过渡和动画时,NavigationView
可能会引起问题,所以你必须决定使用NavigationView
是否值得。你可以像在HIITFit
中那样,在ZStack
层中布置完成按钮,而不使用NavigationView
。
➤ 将你的运行目的地设回iPhone 12 Pro
,因为在画布中更容易预览。
底部的工具条¶
单一的卡片视图在底部会有四个按钮,用来给你的卡片添加元素。
Photos
:从你的照片库中挑选照片Frames
:改变照片元素的形状Stickers
:用应用贴纸为你的卡片添加一些乐趣Text
:添加文字
这些按钮中的每一个都会显示一个单独的模式视图。当你有离散的值,如这四个目的地,你可以使用枚举来创建你的值集。枚举使你的代码易于阅读,并确保值被限制在枚举中定义的那些。
➤ 创建一个名为CardModal.swift
的新Swift
文件。
➤ 添加此代码:
enum CardModal {
case photoPicker, framePicker, stickerPicker, textPicker
}
这些情况对应于每个按钮。
➤ 创建一个新的SwiftUI
视图文件,名为CardBottomToolbar.swift
。
在这个视图中,你将设置四个底部按钮。
➤ 在CardBottomToolbar
上面,为单个工具栏按钮添加一个新的View
:
struct ToolbarButtonView: View {
var body: some View {
VStack {
Image(systemName: "heart.circle")
.font(.largeTitle)
Text("Stickers")
}
.padding(.top)
}
}
每个模态按钮都将使用这个视图,很快你就会把这个样式变得更加通用。
➤ 在CardBottomToolbar
中,为当前模态添加一个绑定:
@Binding var cardModal: CardModal?
➤ 将body
替换为:
var body: some View {
HStack {
Button(action: { cardModal = .stickerPicker }) {
ToolbarButtonView()
}
}
}
在这里你创建了一个HStack
,包含一个按钮,它将改变模态。一会儿你会在这个HStack
中添加更多的工具栏项目。
➤ 修复预览,以发送一个卡片模态绑定:
struct CardBottomToolbar_Previews: PreviewProvider {
static var previews: some View {
CardBottomToolbar(cardModal: .constant(.stickerPicker))
.previewLayout(.sizeThatFits)
.padding()
}
}
➤ 恢复预览,你会看到你的贴纸按钮和图标:
添加底部的工具条¶
➤ 打开CardDetailView.swift
,为CardDetailView
添加一个新的属性:
@State private var currentModal: CardModal?
当你点击底栏上的一个按钮时,该按钮将更新这个属性。稍后将显示相应的模态视图。
➤ 找到.toolbar {
。这是你目前拥有Done
按钮的地方。
➤ 在toolbar(content:)
内添加一个新的工具条项,在之前的工具条项下:
ToolbarItem(placement: .bottomBar) {
CardBottomToolbar(cardModal: $currentModal)
}
在这里,你可以在屏幕底部添加你的新工具栏。
➤ 要看到工具栏,可以预览SingleCardView.swift
或构建并运行该应用程序:
添加其他按钮¶
➤ 打开CardBottomToolbar.swift
,为ToolbarButtonView
添加一个新属性:
let modal: CardModal
ToolbarButtonView
是显示工具条按钮的视图。你将送入按钮所绑定的模态,并显示该按钮的正确图像。你会得到一个编译错误,直到你修复了CardBottomToolbar
。
你已经设置了body
来显示Stickers
按钮的图像和文字。你可以在body中做一个switch
,为所有的CardModal
选项显示相应的图片。然而,更简洁的做法是设置一个所有可能的选项的字典,并加上文本和图像名称。如果你需要复习一下字典,你可以在第8章"保存设置"中首次使用它们。
➤ 将此属性添加到 ToolbarButtonView
:
private let modalButton:
[CardModal: (text: String, imageName: String)] = [
.photoPicker: ("Photos", "photo"),
.framePicker: ("Frames", "square.on.circle"),
.stickerPicker: ("Stickers", "heart.circle"),
.textPicker: ("Text", "textformat")
]
这里你设置了一个[CardModal: (String, String)]
类型的字典,包含所有可能的按钮状态的值。你可以设置一个包含text
和imageName
的结构,但是如果你只在一个对象中使用一次类型,而且代码不多,你可以设置一个叫做tuple
的ad hoc
数据类型。
元组¶
一个元组是一组值。例如,你可以像这样用三个元素初始化一个元组:
let button = ("Stickers", "heart.circle", 1)
并获取数据:
let text = button.0
let number = button.2
显然,给你的类型命名而不是使用数字来访问数据是一个很好的做法,这就是为什么你用(text:imageName:)
来定义你的modalButton
元组。
➤ 在ToolbarButtonView
中,用body
代替:
var body: some View {
if let text = modalButton[modal]?.text,
let imageName = modalButton[modal]?.imageName {
VStack {
Image(systemName: imageName)
.font(.largeTitle)
Text(text)
}
.padding(.top)
}
}
使用你的字典,你可以访问文本和图像名称,然后将这些用于按钮,而不是硬编码的Stickers
值。
➤ 在CardBottomToolbar
中,将HStack
和它的内容替换为:
HStack {
Button(action: { cardModal = .photoPicker }) {
ToolbarButtonView(modal: .photoPicker)
}
Button(action: { cardModal = .framePicker }) {
ToolbarButtonView(modal: .framePicker)
}
Button(action: { cardModal = .stickerPicker }) {
ToolbarButtonView(modal: .stickerPicker)
}
Button(action: { cardModal = .textPicker }) {
ToolbarButtonView(modal: .textPicker)
}
}
这些是你的视图需要的四个按钮。每个按钮都为模态显示了正确的图像和文本,而动作则设置了新的卡模态状态。
➤ 恢复预览,或构建并运行以查看新按钮:
到现在为止,这些按钮还没有做任何事情,但是在接下来的几章中,你将为每个按钮附加一个新的模式视图。
现在你已经有了你的应用程序的主要视图的原型,并且可以直观地看到它们将如何结合在一起。即使在这个早期阶段,原型也是有用的,这样你可以把它展示给其他人看,了解他们对它的看法,以及界面是否足够直观,让他们在没有帮助的情况下浏览。最好是在开发过程中尽早发现你的应用程序没有用处,这样你就可以采纳反馈意见或完全改变方向。
挑战:整理¶
养成定期整理你的应用程序中的文件的习惯。查看文件列表,看看哪些文件可以分组。Command-click
你想分组的每个文件,然后用控制键点击所选文件,选择从选择中新建分组。命名该组。如果你错过了任何文件,只要稍后将它们拖入组中即可。
举个例子,你可以把所有名字里有View
的文件组成一个组,叫做Views
。然后,你可以为一张卡片上使用的视图建立一个子组。
你可以在本章的挑战项目中找到建议的分组。
关键点¶
- 原型总是值得做的。有了原型,就可以更容易地看到缺少什么以及下一步应该怎么做。它们不一定很复杂。到目前为止,你还没有创建或保存任何数据,但你可以知道这个应用程序将如何流动。编写一个应用程序不仅仅是写代码。它是找到你的目标受众,创造一个好的设计和克服技术问题。
- 当你在屏幕顶部有一个按钮时,无论它是领先还是落后,你都可以选择使用导航栏。
NavigationView
的优势在于它是一个标准的、内置的控件,然而,它可以减少你对自定义过渡的选择。 - 字典对于保存不同的值非常有用。例如,你可以用它们来保存格式化视图的选项。使用图元,你可以创建特殊的类型。