跳转至

第13章:概述一个照片拼贴应用程序

祝贺你--你已经写出了你的第一个应用程序!你知道吗?HIITFit使用了标准的iOS用户交互,包括列表和可滑动的页面视图。现在,你将用自定义手势和自定义视图来编写更复杂的东西。

照片拼贴应用程序非常流行,你将建立你自己的拼贴应用程序来创建卡片来分享。你将能够添加图片,从你的照片或从互联网上,并添加文本和贴纸。这个应用程序将是真实世界的,与真实世界的问题相匹配。

在这一章中,你将看一下应用程序想法的草图大纲,并创建一个视图层次,这将是你的应用程序的骨架。

在第2节结束时,你完成的应用程序将看起来像这样:

Final app

初始的应用程序想法

创建一个新的应用程序的第一步是有想法。在编写任何代码之前,你应该研究你的应用程序是否会成为一个热门或失败。弄清楚你的目标受众是谁,并与一些可能使用你的应用程序的人交谈。了解你在App Store中的竞争情况,探讨你的应用程序如何提供新的和不同的东西。

一旦你决定你有一个成功的机会,就把你的应用程序画出来,并找出可行性和可能存在的技术困难。

你的照片拼贴应用程序将有一个主视图--在那里你列出所有的卡片--和一个选定卡片的详细视图--在那里你可以添加照片和文字。这可能是草图的背面。

Back of the napkin sketch

在接下来的章节中,你将设置数据模型和数据存储,但现在,请检查设计并思考可能需要克服的实施困难。始终采取模块化的方法,尽可能将应用程序的每个方面与主应用程序分开测试。

SwiftUI在这方面做得很好,因为你可以使用SwiftUI的实时预览功能独立构建视图和控件。当你对一个视图的工作方式感到满意时,就把它添加到你的应用程序中。

创建项目

在上一节中,你从一个包含创建HIITFit所需的所有资产的启动程序开始。在本节中,你将从一个新的应用程序开始,在接下来的几章中,你会发现如何添加资产。

➤ 打开Xcode并选择File ▸ New ▸ Project并使用iOS App模板创建一个名为Cards的新项目。如果你需要复习一下如何创建一个新的SwiftUI项目,你可以在第1章"检查你的工具"中找到所有信息。

➤ 单击运行目的地按钮,选择iPhone 12 Pro。使用Command-R构建并运行你的应用程序,以确保一切工作正常。你的iPhone 12 Pro模拟器应该启动并显示ContentViewHello, world!文本。

Initial screen

每次创建一个新的应用程序时,你都应该采取这些步骤,以防你的环境发生了变化。

为你的项目创建第一个视图

你将在本节中学习的技能:ScrollView

➤ 创建一个名为CardsListView.swiftSwiftUI视图文件。

此视图将显示您在应用程序中创建的所有卡片的滚动缩略图列表。

创建一个卡片的列表

➤ 打开CardsListView.swift。目前,你将显示一个圆角矩形的占位符列表,而不是卡片。

➤ 用以下语句替换body

var body: some View {
  ScrollView {
    VStack {
      ForEach(0..<10) { _ in
        RoundedRectangle(cornerRadius: 15)
          .foregroundColor(.gray)
          .frame(width: 150, height: 250)
      }
    }
  }
}

这将在一个可滚动的VStack中放置十个形状。

Placeholder thumbnails

一个ScrollView可以是垂直的或水平的。这里使用的默认值是垂直的,但你可以用ScrollView(.horizontal)指定一个水平轴。

➤ 实时预览该视图,就可以滚动列表了。当你滚动时,你可以看到卡片边上有一个丑陋的滚动条。

➤ 如果看不到画布,可以用Xcode右上方的图标来启用它:

Show canvas

➤ 将ScrollView {改为:

ScrollView(showsIndicators: false) {

这将关闭滚动条。

With and without the scroll bar

重构视图

本节中你将学习的技能:重构视图;环境对象中的视图状态

当你添加视图时,你会认识到以后有些视图会变得更加复杂。RoundedRectangle就是这样一个视图。你已经给了它基本的样式,但你可能还想进一步给它样式。在早期重构视图要容易得多,所以你现在要为占位卡创建一个新视图。你在第三章"主视图的原型设计"中提取了一个视图,所以这对你来说应该是一个复习。

➤ 创建一个新的SwiftUI视图文件,名为CardThumbnailView.swift

➤ 回到CardsListView.swift中,命令单击RoundedRectangle并选择提取子视图。

Name the subview

➤ 将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.swiftSwiftUI视图文件。

➤ 一个卡片将有一个彩色的背景,你将在上面添加照片、贴纸和文本。

➤ 将body替换为:

var body: some View {
  Color.yellow
}

这种颜色最终将来自卡片数据,但目前你只需让卡片变成黄色。

A yellow card

从列表过渡到卡片

当你在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()
}

这将在你点击一个卡片缩略图时切换布尔值。当showAllCardsfalse时,你将在卡片列表的前面显示所选的单张卡片。

➤ 创建一个新的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就会显示在卡片列表的顶部。

Transition from thumbnail to card

要从SingleCardView返回到缩略图列表,你需要创建一个Done按钮。

在处理这个按钮之前,先将你的应用程序设置为在模拟器中运行,这样,如果实时预览失败,你仍然可以看到你的应用程序。

➤ 打开CardsApp.swift,将ViewState初始化为一个状态对象:

@StateObject var viewState = ViewState()

你这样做是为了让viewState在你的应用程序中一直存在。如果你只是简单地把它初始化为CardsApp中的环境对象,偶尔应用程序会重新初始化它,而且,如果你正在编辑卡片,你会神秘地回到第一个屏幕。

➤ 将ContentView()改为:

CardsView()
  .environmentObject(viewState)

你调用将显示卡片列表的视图,而不是ContentView,确保你把viewState放入应用环境中。

➤ 构建并运行,确保你的应用程序在模拟器中工作,就像在预览中一样。

你不再使用ContentView.swift了,但你可以把它留在项目中,以试验其他SwiftUI布局。

本节将学习的技能:工具条;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:)允许多个ToolbarItemplacement可以是:

  • navigationBarLeading: 顶部导航栏的前缘。
  • navigationBarTrailing: 顶部导航栏的尾部边缘。
  • principal: 在iOS上,主要位置是在导航条的中心。
  • bottomBar: 底部工具条。

你很快就会用到底部工具条的位置。

➤ 预览 SingleCardView

No Done button

Note

这个按钮没有显示出来。这是因为ToolbarItem(place:)使用了navigationBarTrailing,所以任何项目只有在视图处于NavigationView内时才会显示。

➤ 在SingleCardView中,命令点击Color并选择Embed...

➤ 将占位符Container改为NavigationView

➤ 恢复预览,你的按钮将显示出来。

Navigation bar Done button

添加一个导航栏

当你使用List时,你经常会同时使用NavigationViewNavigationLink,它们有内置的推送和弹出过渡和标题。你将在第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)

这设置了导航栏的样式。

其他风格包括automaticlarge。如果你想给视图一个标题,你可以使用.navigationTitle("标题在这里")

➤ 预览 SingleCardView以查看风格化的导航栏。

Styling the navigation bar

➤ 在纵向的iPad模拟器上建立和运行。可以按Command-Right箭头和Command-Left箭头来旋转模拟器,或者选择Device ▸ Orientation并选择所需的方向。

➤ 点一个卡片。

你会看到,你会得到一个白色的屏幕,上面有一个返回按钮。当你点击返回时,你会看到SingleCardView的一部分和Done按钮。点击Done会让你回到第一个屏幕。这是由于NavigationView在不同大小的配置下表现不同。

iPad navigation view

在第16章"向你的应用程序添加资产"中,你会了解到不同的设备有不同的尺寸类别。除了iPad模拟器,你还可以在横向的iPhone 12 Pro Max模拟器上复制,因为这也有一个较大的尺寸类。

➤ 在SingleCardView中,给NavigationView添加一个修改器:

.navigationViewStyle(StackNavigationViewStyle())

这种导航视图样式可以确保每次只看到一个顶视图。

➤ 构建并运行,现在您的应用程序的iPad版本的行为方式将与iPhone 12 Pro版本相同。

iPad single navigation view

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

➤ 恢复预览,你会看到你的贴纸按钮和图标:

Stickers button

添加底部的工具条

➤ 打开CardDetailView.swift,为CardDetailView添加一个新的属性:

@State private var currentModal: CardModal?

当你点击底栏上的一个按钮时,该按钮将更新这个属性。稍后将显示相应的模态视图。

➤ 找到.toolbar {。这是你目前拥有Done按钮的地方。

➤ 在toolbar(content:)内添加一个新的工具条项,在之前的工具条项下:

ToolbarItem(placement: .bottomBar) {
  CardBottomToolbar(cardModal: $currentModal)
}

在这里,你可以在屏幕底部添加你的新工具栏。

➤ 要看到工具栏,可以预览SingleCardView.swift或构建并运行该应用程序:

Bottom toolbar

添加其他按钮

➤ 打开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)]类型的字典,包含所有可能的按钮状态的值。你可以设置一个包含textimageName的结构,但是如果你只在一个对象中使用一次类型,而且代码不多,你可以设置一个叫做tuplead 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)
  }
}

这些是你的视图需要的四个按钮。每个按钮都为模态显示了正确的图像和文本,而动作则设置了新的卡模态状态。

➤ 恢复预览,或构建并运行以查看新按钮:

Button Preview

到现在为止,这些按钮还没有做任何事情,但是在接下来的几章中,你将为每个按钮附加一个新的模式视图。

现在你已经有了你的应用程序的主要视图的原型,并且可以直观地看到它们将如何结合在一起。即使在这个早期阶段,原型也是有用的,这样你可以把它展示给其他人看,了解他们对它的看法,以及界面是否足够直观,让他们在没有帮助的情况下浏览。最好是在开发过程中尽早发现你的应用程序没有用处,这样你就可以采纳反馈意见或完全改变方向。

挑战:整理

养成定期整理你的应用程序中的文件的习惯。查看文件列表,看看哪些文件可以分组。Command-click你想分组的每个文件,然后用控制键点击所选文件,选择从选择中新建分组。命名该组。如果你错过了任何文件,只要稍后将它们拖入组中即可。

举个例子,你可以把所有名字里有View的文件组成一个组,叫做Views。然后,你可以为一张卡片上使用的视图建立一个子组。

你可以在本章的挑战项目中找到建议的分组。

关键点

  • 原型总是值得做的。有了原型,就可以更容易地看到缺少什么以及下一步应该怎么做。它们不一定很复杂。到目前为止,你还没有创建或保存任何数据,但你可以知道这个应用程序将如何流动。编写一个应用程序不仅仅是写代码。它是找到你的目标受众,创造一个好的设计和克服技术问题。
  • 当你在屏幕顶部有一个按钮时,无论它是领先还是落后,你都可以选择使用导航栏。NavigationView的优势在于它是一个标准的、内置的控件,然而,它可以减少你对自定义过渡的选择。
  • 字典对于保存不同的值非常有用。例如,你可以用它们来保存格式化视图的选项。使用图元,你可以创建特殊的类型。