SwiftUI-应用动画指南-全-

SwiftUI 应用动画指南(全)

原文:zh.annas-archive.org/md5/49e3981ef4a39557d6a7c5877acc7c98

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读 Animating SwiftUI Applications 这本书,它旨在帮助您使用 SwiftUI 动画创建引人入胜和交互式的用户体验。

SwiftUI 是一个现代的、声明式的、直观的框架,用于在 Apple 平台上构建用户界面,并且它附带了一个强大且易于使用的动画系统,让您能够轻松地为您的应用添加运动和生命力。

使用 SwiftUI 动画,您可以为应用的 UI 元素添加动态过渡、平滑运动和令人愉悦的效果,使它们对用户来说更加吸引人和直观。您可以使用几行代码来动画化任何内容,从简单的按钮和文本字段到复杂的布局和自定义形状。

在这本书中,您将学习如何使用 SwiftUI 动画创建各种类型的效果,包括过渡、手势,甚至是 SpriteKit 动画。您将探索不同的动画技术,如隐式、显式和组合动画,并学习如何将它们应用于创建令人惊叹且响应灵敏的用户界面。到本书结束时,您将能够创建美丽且引人入胜的动画和应用,它们真正地脱颖而出。

本书面向对象

本书面向那些对 Swift 有基本工作知识但 SwiftUI 是初学者的读者。本书从 SwiftUI 框架的一些基础知识开始,然后通过简单的动画示例进行,最后进入更高级的主题。

本书涵盖内容

第一章 探索 SwiftUI 的基础知识中,您将获得两种不同编程风格的全面概述:命令式和声明式。然后,我们将深入了解 Apple 提供的免费应用程序 Xcode 的界面,我们所有的开发工作都将在这里进行。最后,我们将检查创建应用程序所需的根本 SwiftUI 结构,为动画的进步打下基础。

第二章 理解 SwiftUI 中的动画中,我们将探讨动画的机制,包括时间曲线和可动画属性等基本主题。这将为我们将要构建的项目奠定坚实的基础。

第三章 创建呼吸应用中,我们将利用 SwiftUI 的用户友好型修饰符和设计工具轻松地旋转、缩放和移动视图。此外,我们将使用 animation 修饰符在起始值和结束值之间平滑插值,产生无缝的动画。

第四章 构建唱片机中,我们将把 Asset Catalog 中的图像整合到我们的代码中。然后,我们将创建独立的文件来创建各种视图,这些视图可以无缝集成到主视图中进行显示。此外,我们将添加一个按钮来触发动画,并将声音整合到项目中。

第五章动画彩色万花筒效果,我们将探索使用hueRotation修饰符进行颜色动画,其中hue指的是对象的颜色,而rotation表示这些颜色的旋转或动画。我们将构建一个简单的项目来展示各种图像,然后使用hueRotation来改变图像的颜色,产生类似万花筒的效果。

第六章动画秋千上的女孩,我们将研究将图像切割成单独部分的过程,然后使用代码以创造性和独特的方式将这些单独部分一起进行动画。此外,我们将利用一个新的修饰符mask来帮助隐藏我们不想看到动画的特定区域。

第七章构建一系列皮带和齿轮,我们将通过使用stroke修饰符来生成“行军蚂蚁”效果,然后探讨如何使用 Pragma 标记和组来整洁地组织代码。我们还将利用rotation3Deffect修饰符沿着所有三个轴(xy,和z)对对象进行动画。

第八章动画花朵,我们将通过使用blurscalerotationEffect等修饰符来构建一束花朵呼吸的错觉。此外,我们将研究UIViewRepresentable协议和CAEmitter类,以将下雪效果融入场景。

第九章在形状周围动画线条,我们将深入了解将位图图像转换为矢量文件的过程,然后将这些矢量文件转换为可以在 SwiftUI 项目中使用的代码。此外,我们将通过使用stroke修饰符和计时器来在各个形状周围动画线条。

第十章创建海洋场景,我们将利用Shape协议来制作一个具有起伏波浪、在水中摇摆的浮标、闪烁的灯光和伴随声音的海洋场景。

第十一章动画电梯,我们将使用GeometryReaderproxy常量将图像插入项目并适当地定位它们。这将使图像能够根据使用的设备动态调整大小。此外,我们将创建一个DataModel来存储应用程序的数据和功能,并使用计时器在场景的各个位置激活门和灯光动画。

第十二章创建文字游戏 - 第一部分,我们将开始构建一个基于文字的游戏,允许玩家使用三种不同的语言输入单词进行游戏,并设有验证机制来确认他们的选择。

第十三章 创建单词游戏 - 第二部分中,我们将通过在DataModel中组织其属性和函数以及创建独特的头部和尾部视图来显示信息给用户,为我们的单词游戏添加最后的修饰。此外,我们还将以三种不同的形式融入用户反馈:弹出警报、触觉反馈和音频。

第十四章 创建颜色游戏中,我们将开发一个 RGB 颜色匹配游戏,该游戏会跟踪得分,并在玩家获得完美得分时显示庆祝的彩带动画。我们还将检查 Swift 包与项目的集成。

第十五章 将 SpriteKit 集成到您的 SwiftUI 项目中中,我们将创建六个独立的项目,展示 SpriteKit 和各种粒子系统。我们将学习如何将真实流畅的动画与 SwiftUI 代码无缝融合。

要充分利用本书

要充分利用本书,需要具备 Swift 编程语言的实用知识。了解 SwiftUI 的基本知识有帮助,但不是必需的。

您还需要一台 Mac 电脑,并已下载免费的软件 Xcode(Xcode 在大多数 macOS 版本上运行良好,因此应该与您拥有的版本兼容)。

我们将为 iOS 设备构建我们的项目。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

如果您更喜欢通过视频教程学习,我推荐您访问udemy.com上的 Swift 和 SwiftUI 课程。其他高度推荐的资源,可以帮助您快速提高编码技能,包括保罗·哈德森在hackingwithswift.com的作品,以及 J.D. Gauchat 的SwiftUI for Masterminds书籍。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Animating-SwiftUI-Applications。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载颜色图像

我们还提供了一份包含本书中使用的截图和图表的颜色图像的 PDF 文件。您可以从这里下载:packt.link/O1ZYe

使用的约定

在本书中使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“他非常细致,你可以在jdgauchat.com找到他的作品。”

代码块设置如下:

struct ContentView: View {
    var body: some View {
        HStack {
            Text(“Hello”)
            Spacer()

当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:

struct ContentView: View {
   var body: some View {
       VStack(alignment: .leading)  {
      Text(“Hi, I’m child one in this vertical stack”)
      Text(“Hi, I’m child two in this vertical stack”)

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“我们将使用第一个选项,即创建一个新的 Xcode 项目,用于所有我们的项目,因此请选择该选项。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们读者的反馈始终受到欢迎。

一般反馈:如果您对此书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Animating SwiftUI Applications》,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接

https://packt.link/free-ebook/9781803232669

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:探索 SwiftUI 基础

欢迎来到 Animating SwiftUI Applications!如果你拿起这本书,那么你很可能是一名开发者——或者正在努力成为开发者——并且你想要了解更多关于 SwiftUI 动画的知识。或者也许你对动画以及它们是如何工作的很感兴趣,就像我一样。我知道对我来说,第一次玩电子游戏(在家用电脑出现之前)并看到屏幕上的物体相互碰撞并弹跳,我被动画以及它们工作背后的代码深深吸引。无论你为什么来到这里,但我们将一起探索利用 SwiftUI 动画类、方法和属性的力量,在苹果设备上可以实现的惊人事物。

本章将从简要介绍两种编程风格,命令式和声明式,并给你一个为什么苹果将声明式的 SwiftUI 编程方式引入开发世界的原因。然后,我们将探索苹果提供的免费应用程序 Xcode 的界面,在那里我们完成所有的工作。最后,我们将查看开发应用程序所需的 SwiftUI 结构,这是进一步进行动画的基础。

在本章中,我们将涵盖以下主题:

  • 理解命令式和声明式编程

  • 探索 Xcode 界面

  • 理解状态

  • 理解 SwiftUI 结构

技术要求

为了编写能在苹果设备上运行的代码,首先,我们需要一台苹果电脑。这可以是他们任何一款型号,但 MacBook Pro 因其强大的性能和速度,是编码中最受欢迎的选择。

一旦我们有了硬件,接下来我们需要的技术来编写代码就是软件。苹果已经将一套非常全面的工具捆绑在一个名为 Xcode 的程序中,可以从 App Store 免费下载。这两样东西就是你开始编写苹果代码所需的一切,但如果你想要将你的完成的应用程序上传到 App Store,那么你需要一个苹果开发者账户。目前,维护这个账户每年需要 99 美元,但这是将你的应用程序销售给全世界所必需的。请访问developer.apple.com并在那里注册账户。

你还应该具备 Swift 编程语言的实际知识,这样你才能在编写代码时感到舒适,但你不一定需要成为专家;只是如果你理解,或者至少能识别 Swift 语言的语法和面向对象编程(OOP)的基础,这将非常有帮助,这样你就能更好地跟随项目进行。

话虽如此,如果您是编写代码的初学者,您可能会在这里感到有些困惑——但不用担心,当苹果在 2014 年推出 Swift 编程语言时,他们坚持了他们的目标,即创建迄今为止最容易上手和最用户友好的编程语言之一。而且,从很大程度上说,Swift 语言读起来就像英语句子,所以您可以非常快速地进步。

当您刚开始学习 Swift 时,我推荐以下内容:

从保罗·哈德森提供的 Swift 教程开始。他是一位出色的 Swift 程序员,也是业界最富有创造力的人之一。他已经整理了大量免费 Swift 培训教程和视频,帮助您快速编写代码。我曾与保罗合作过许多项目,您很难找到比他更好、更全面的授课风格——他也是一个非常和善的人。请访问他的所有资料hackingwithswift.com

我还与另一位(并且继续与他合作)的人约翰·D·高查特合作过。他已经整理了一套 Swift 和 SwiftUI 大师系列书籍,这些书籍可以作为参考和指南/代码手册,当您需要记住语法或快速实现某事时使用。他同样非常全面,您可以在jdgauchat.com找到他的作品。

最后,如果您喜欢结构化的视频课程,我已经将保罗和约翰的许多 Swift 和 SwiftUI 书籍翻译成了视频课程,它们在udemy.com上提供——只需搜索我的名字即可查看所有课程,包括本书的视频版本(其中包含额外项目)。

好了,这些令人尴尬的推荐就到这里,但如果您是编程的初学者,我希望您从最好的 Swift 编程语言开始学习,这两位就是;这样,您将很快准备好跟随并编写代码。所以,去获取一些 Swift 知识,然后回来这里。我会等你的…

最后,要访问本书中的所有代码,请访问以下 GitHub 仓库:github.com/PacktPublishing/Animating-SwiftUI-Applications

理解命令式和声明式编程

SwiftUI 是苹果在 2019 年推出的一种相对较新的框架,它包括直观的新设计工具,可以帮助构建看起来很棒的界面,几乎就像拖放一样简单…几乎。凭借其模块化方法,据估计,您可以使用大约五分之一的代码构建之前在 Xcode 中构建的相同项目。此外,SwiftUI 是苹果为构建可以在其所有其他平台上轻松使用的应用程序的解决方案——因此,一个应用程序只需构建一次,就可以在 iOS、tvOS、macOS 和 watchOS 上完美运行。

我们将在稍后介绍 SwiftUI 界面,它使用协同工作的编辑器和画布预览窗口。当你使用 Xcode 编辑器编写代码时,新的设计画布会完全同步显示,并且在你键入时实时渲染。因此,你在编辑器中做出的任何更改都会立即反映在画布预览中,反之亦然。

我之前提到过代码中拖放操作的便捷性;这是因为那些优秀的苹果工程师们肯定花费了无数个夜晚辛勤工作,精心打造了一个庞大的预置代码块集合,称为视图和修饰符,你可以直接拖放到编辑器中。这包括按钮、标签、菜单、列表、选择器、表单、文本字段、切换开关、修饰符、事件、导航对象、效果等等,但关键点在于此。与 UIKit 和 Storyboards 不同,当你将视图或修饰符拖放到编辑器或画布上时,SwiftUI 会自动生成该视图的代码。

SwiftUI 的 app 开发方法被称为声明式编程,在过去的几年中已经变得非常流行。声明式编程的例子包括 React 框架以及跨平台开发框架如 React Native 和 Flutter。因此,现在是苹果公司推出它自己的完全本地的声明式 UI 框架 SwiftUI 的时候了。

但声明式编程实际上意味着什么呢?好吧,为了最好地描述声明式编程,让我们首先了解命令式编程是什么。

命令式编程是自计算机语言诞生以来最古老的编程范式。单词“imperative”源自拉丁语单词“imperare”,意为“命令”,它最初被用来表达命令——例如:“做它!”(嗯,我想知道耐克是否借鉴了这个命令式指令并稍作修改……)这种编程风格是 SwiftUI 推出之前 iOS 开发者所使用的。

命令式编程是一种编程范式,它使用改变程序状态的语句。这些语句按照特定顺序执行,通常涉及赋值语句、循环和控制结构,这些结构指定了计算应该如何执行。

在命令式编程中,程序员通过告诉计算机做什么来指定计算的具体执行方式。这可能会使命令式程序更加复杂,因为程序员必须指定计算的所有步骤。然而,这也可能使它们更加灵活,因为程序员对计算的细节有更多的控制。

下面是一个使用 iOS 中 UIKit 框架进行命令式编程的例子:

let button = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 50))
button.setTitle("Button", for: .normal)
button.setTitleColor(.black, for: .normal)
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
view.addSubview(button)

这段代码创建了一个新的 UIButton,设置了其标题和标题颜色,并添加了在按钮被点击时执行的操作。然后它将按钮添加到视图层次结构中。这段代码是命令式的,因为它指定了创建和配置按钮并将其添加到视图的精确步骤。

现在,这段相同的代码也可以使用类似 SwiftUI、ReactiveCocoa 或 RxSwift 这样的库以声明式风格编写,这些库允许你指定按钮的期望行为而不是实现它的步骤。以下是使用 SwiftUI 编写的相同示例:

import SwiftUI
struct ContentView: View {
    var body: some View {
        Button(action: {
            // action to be performed when button is tapped
        }) {
            Text("Button")
                .font(.title)
                .foregroundColor(.black)
        }
    }
}

在这个例子中,Button 视图是声明式的,因为它指定了按钮的期望行为(显示文本并在点击时执行操作),而不是创建和配置按钮所需的步骤。

SwiftUI 使用声明式编程风格,这使得理解和维护代码变得更加容易,因为你不需要指定实现所需行为所需的所有中间步骤。它还允许你的代码在底层数据发生变化时自动更新,因为你指定的是期望的结果而不是实现它的步骤。

因此,声明式编程是一种编程范式,其中程序指定它想要实现的内容,而不是如何实现它。重点是“什么”而不是“如何”。声明式程序通常更容易理解,因为它们不需要程序员指定计算的每个步骤。它们也可以更简洁,因为它们不需要指定所有中间步骤。

有许多不同的编程语言和技术支持声明式编程,包括 SQL、HTML 以及 Haskell 和 Lisp 这样的函数式编程语言。一般来说,声明式编程非常适合涉及定义数据关系或指定期望输出的任务,而不是指定实现某事的步骤。

为了澄清,让我们用艺术家的类比:命令式语言通过数字绘画以达到预期的结果,即完成的作品,而声明式语言使用完成的作品,让后台算法(函数和方法)自动选择合适的颜色和笔触以达到预期的结果。此外,通过使用这种声明式方法,SwiftUI 最小化或消除了通常由跟踪程序状态引起的编程副作用。

注意

你需要记住,在许多情况下,代码将是命令式和声明式风格的混合体,所以它不总是单一的风格。

现在我们对 SwiftUI 有更好的理解,我们将继续介绍 Xcode 界面概述。

探索 Xcode 界面

在本节中,我们将游览 Xcode 界面。我假设您之前已经使用过 Xcode,练习 Swift 技能,这意味着您已经很好地掌握了界面中的许多事物。然而,有一些新功能是为了适应 SwiftUI 而添加的。

当您第一次启动 Xcode 时,您会看到欢迎界面。在右侧是一个最近项目的列表,在左侧有按钮可以开始一个新项目、打开一个现有项目或克隆一个存储在仓库中的项目。

图 1.1:Xcode 欢迎界面

图 1.1:Xcode 欢迎界面

我们将使用第一个选项,即 创建一个新的 Xcode 项目,用于我们所有的项目,因此请选择该选项。

下一屏允许我们选择项目的选项:

图 1.2:项目选项

图 1.2:项目选项

让我们来看看这些选项:

  • 产品名称: 这将是项目的名称。您应该选择一个与项目将要执行的任务直接相关的名称。

  • 团队: 这将是您使用 Apple ID 在 developer.apple.com 创建的开发者账户,或者如果您有的话,也可以是公司账户。

  • com.SMDAppTech)。

注意

如果您想知道为什么苹果要求这种反向概念,这里有一个更深入的解释。反向域名命名法(或反向 DNS)是一种将 IP 地址映射到域名系统的系统。反向 DNS 字符串基于注册的域名,为了分组目的,组件的顺序被反转。以下是一个示例:如果一个名为 MyProduct 的产品公司拥有 exampleDomain.com 的域名,他们可以使用 com.exampleDomain.MyProduct 作为该产品的标识符。反向 DNS 名称是一种简单消除命名空间冲突的方法,因为任何域名都是其注册所有者的全球唯一标识符。

  • 界面: 这是选择我们将用于设计 UI 的技术的位置。从下拉列表中,您可以选择 SwiftUIStoryboard。SwiftUI 是一个系统,允许我们从代码中声明界面,而 Storyboard 是一个图形系统,允许我们将许多组件和控制拖放到故事板中,以创建用户界面 - 我们想要选择 SwiftUI

一个快速说明:尽管我们可以在那个选项中将对象拖放到故事板中,但在您将对象拖放到板上后,并不会生成代码。您仍然需要为每个对象编写代码,并为按钮和其他控件建立连接。而在 SwiftUI 中,您可以拖放类似的组件,并且代码会自动在编辑器中传播。然后您填写主体,使其执行您想要的功能。

  • 语言: 这是编程语言;在这里,我们将选择 Swift

  • 我们不需要使用核心数据,这是一种持久化数据的方式,以便它总是可以再次加载,并且我们不需要为任何项目包含测试。

当我们点击下一步时,系统会询问我们希望将项目保存到何处。我喜欢将其保存在桌面上,但你可以选择任何你想要的位置。

现在我们已经进入了 Xcode 界面:

图 1.3:Xcode 界面

图 1.3:Xcode 界面

Xcode 界面是我们在其中进行所有编码的 Xcode 部分。它被分成不同的部分;以下是这些部分的解释:

  • 项目导航1):

这是一个可折叠的空间,包含了所有项目文件。有一个名为 ContentView 的文件在这里,我们可以将代码写入其中,并且随着项目的增长,我们可以创建更多文件。如果你之前使用过 Xcode 并与 UIKit 一起工作过,那么 ContentView 文件类似于 UIKit 的 View Controller,其中包含 ViewDidLoad 方法,我们通常会在其中加载一些用户界面代码。在这里,我们将 UI 代码放在 ContentView 结构中,并根据需要创建其他结构。我们还可以通过创建和命名新的组和文件夹来组织所有这些文件。这里还有一个名为 Assets.xcassets 的文件(也称为资产目录),我们将项目所需的图像和颜色放置在这里。

查看顶部的 Demo。这是你的项目的主要文件夹,其中包含了放置所有其他内容的地方,包括你创建的新 Swift 文件。点击它将带我们进入许多不同的选项和设置来配置应用,例如部署目标、签名、功能、构建设置等。

  • 工具栏2):

让我们看看工具栏(在交通灯按钮之后),从左到右依次是:

  • 这里有一个导航切换按钮,可以打开和关闭我们刚刚查看的项目导航面板,在你需要更多工作空间时提供帮助。

  • 在导航按钮的右侧有一个播放按钮,用于运行和停止项目。

  • 接下来,你会看到你项目的标题。然而,如果你点击它,这将弹出一个下拉列表,允许你选择方案以及各种不同的模拟器或设备来运行你的项目。方案是运行应用的目的地。例如,Xcode 允许我们在不同的模拟器、设备、Mac 电脑上的窗口、Apple Watch、iPad 或 Apple TV 上运行项目(如果我们在为这些设备构建)。我们正在为 iPhone 构建,所以你可以从列表中选择任何 iPhone 模拟器模型,或者将你的 iPhone 连接到你的电脑,你会在列表中看到它。如果你选择了你的 iPhone,你可以看到你的应用在实际设备上运行的样子。

  • 之后,有一个显示区域,用于显示任何错误或警告,以及应用当前的运行状态。

  • 在工具栏的右侧有一个加号按钮,它打开了一个工具库,我们使用这些工具来帮助创建用户界面,例如修饰符、视图、控件和代码片段。

  • 最后,还有一个按钮在右侧,用于显示或折叠实用工具检查器,以便在需要时获得更多屏幕空间。

  • 实用工具3):

实用工具检查器是一个可折叠区域,提供了更多编辑和配置界面及其元素选项。顶部有五个标签用于此目的;从左到右,它们如下:

  • 文件检查器用于调整你正在工作的文件的参数

  • 历史检查器用于查看你的项目历史(在 SwiftUI 项目中使用不多)

  • 快速帮助检查器会给出编辑器中选中代码的描述/定义

  • 无障碍性检查器用于配置诸如语音覆盖、盲文阅读以及其他与使你的应用更具无障碍性相关的设置

  • 属性检查器为你提供了更改所选特定视图、修饰符或其他控件属性的选择

  • 调试/控制台4):

这是一个可以通过点击右下角的按钮来展开和收起的可折叠空间。该区域也可以分为两个部分。当分割时,左侧部分提供调试信息,右侧是一个控制台,用于在运行代码时显示任何相关信息,以及警告和错误。

  • 编辑器5):

这是编写代码的区域。Xcode 的这个部分是不可折叠的,但我们可以通过点击位于工具栏下方右侧的按钮将其分成两个或更多部分。它可以根据你喜欢的编写代码的方式放置在顶部或底部。

此外,还有一个名为迷你图的功能,是代码文件的微型地图,它提供了一个整个文件的视图,并使得参考和导航你的代码变得容易,尤其是如果你有非常大的文件。我们可以通过点击右上角的汉堡图标并选择迷你图来启用它。

  • 画布 预览6):

画布是 Xcode 的一个可折叠区域,它包含一个名为预览的图形模拟器,该模拟器与编辑器内的代码实时连接。我们在编辑器中做出的任何更改都会反映在预览中。预览中有一个运行按钮,可以测试你到目前为止所做的工作,但预览是一个很好的视觉辅助工具,有助于加快开发速度。

这就是 XCode 界面的概览。一开始可能看起来令人畏惧,但随着你在项目中编码,你会变得更加舒适,并开始了解一切所在的位置。

让我们继续并看看状态的概念,状态是可以改变的数据。我们通过变量持有我们的数据,当我们在 SwiftUI 中动画化某个东西时,这些数据会多次改变;当数据改变时,SwiftUI 会通过使用状态来刷新视图,帮助我们更新动画。

理解状态

在 SwiftUI 中,状态是可以改变的数据片段。当状态改变时,依赖于该状态的观点会自动刷新。

你可以通过使用 @State 属性包装器在 SwiftUI 视图中声明一个状态。例如,参见以下:

struct ContentView: View {
    @State private var name: String = "Bella"
}

在这里,name 是一个存储为字符串的状态。然后你可以使用这个状态在你的视图中显示动态内容:

struct ContentView: View {
    @State private var name: String = "Bella"
    var body: some View {
        Text("Hello, \(name)")
    }
}

要更改状态,我们可以将新的值分配给 @State 属性。例如,参见以下:

struct ContentView: View {
    @State private var name: String = "Bella"
        var body: some View {
        VStack {
            Text("Hello, \(name)")
            Button(action: {
                name = "Jack"
            }) {
                Text("Change name")
            }
        }
    }
}

当按钮被点击时,名称状态会变为 "Jack",视图会自动刷新以显示新的名称。

让我们继续前进,看看是什么构成了 SwiftUI 以及它为何能如此高效地工作。

理解 SwiftUI 结构

SwiftUI 为我们提供了用于声明用户界面的视图、控件、修饰符和布局结构。该框架还包括事件处理器,用于为我们的应用提供点击、手势和其他类型的输入,以及管理从应用模型中来的数据流量的工具。

但什么是模型?模型简单来说就是我们创建在项目导航器窗口中的一个文件夹,我们通常在其中保存应用的数据;例如,如果我们正在开发一个天气应用,我们可以在从互联网通过应用程序编程接口API)调用接收到的数据后,将风速、温度、降水量和积雪累积数据保存在应用模型中。这些数据随后将被处理并发送到用户将看到并能与之交互的视图和控制。

SwiftUI 允许我们避免使用 Interface Builder 和 Storyboards 来设计应用的用户界面,因为我们可以使用预览画布和编辑器。我们可以在编写代码时检查用户界面,并在将视图/控件拖放到画布时生成代码。编辑器和画布预览中的代码是并排的;更改其中一个将更新另一个。

在构建我们的应用时,我们使用 视图。在 SwiftUI 中,几乎所有东西都是一个视图,它们是应用的基本构建块,例如文本框、按钮、开关、选择器、形状、颜色(是的,颜色也是一个视图)、堆叠等。我们可以通过从视图库中拖拽它们到画布上,或者通过在编辑器中键入代码并将它们的属性与修饰符一起设置来添加它们。每个视图都将有其自己独特的一组属性和修饰符,许多视图也会共享那些相同的属性和修饰符。

以下是我们将查看的 SwiftUI 结构,以便你为完成本书的项目打下良好的基础:

  • 计算属性

  • 堆叠(VStackHStackZStack

  • Spacer 视图

  • Divider 视图

  • padding 修饰符

  • 闭包

  • GeometryReader

让我们逐一介绍这些内容。

计算属性

我们将要首先关注的是 计算属性,因为这就是视图是如何创建的。这里是我们第一次创建一个新的 SwiftUI 项目时看到的模板代码:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
    }
}

如果我们运行这段代码,在预览窗口中我们会看到以下内容:

图 1.4:运行模板代码

图 1.4:运行模板代码

Hello, world! 字符串显示在屏幕中间。

查看代码,我们看到它包含一个名为 ContentViewstruct 对象,这是 SwiftUI 为我们创建的基本构建块结构体。在这个结构体内部有一个名为 body 的计算属性——它有开闭括号,是的,它有一个放置你代码以执行的地方,就像 Swift 中的任何函数一样。计算属性不会像常规存储属性或变量那样存储值。相反,这个属性将计算你放在括号之间的代码,然后返回结果。

计算属性可以有 getter 和或 setter。它们可以获取并返回一个值,设置一个新值,或者两者都做。然而,如果它只有一个 getter,那么它就被称为只读属性,因为它只会返回计算属性的值。

你可能会想知道这是否是一个计算只读属性,那么 getter 和 return 关键字在哪里?嗯,实际上这段代码是计算属性的简写版本。写长版本是可选的,但如果我们想写更易读和描述性的代码,我们可以这样做;如果那样做,它看起来会像这样:

struct ContentView: View {
    var body: some View {
        get {
            return VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
                Text("Hello, world!")
            }
            .padding()
        }
    }
}

这段代码与之前做的是同一件事:它返回 getreturn。再次强调,它们是可选的,所以在大多数情况下,你会看到简写版本,因为代码越少,生活就越简单。然而,许多开发者更喜欢添加这些关键字带来的清晰度...这取决于你。

这里还有一个有趣的语法点,那就是 some View 关键字。some 关键字表示将返回一个不透明类型,而在这个例子中,View 是不透明类型。不透明指的是那些不清晰或不容易理解的东西。那么,什么是不清晰的?那就是这个视图将返回的类型。这是因为,作为一个不透明类型,它隐藏了类型及其实现。它唯一关心的是返回一个单一的视图,这很重要,因为只有单个视图才能满足 some 关键字的要求。返回的视图类型取决于我们放入计算属性体中的内容。在代码中,有一个 Text 视图,所以返回的类型就是它。

当我们创建自定义视图时,我们只需确保它符合 View 协议。要做到这一点,我们只需要实现所需的 body 计算属性,然后我们可以添加任何我们想要显示的视图,比如按钮、开关、选择器、形状、颜色等等,但同样,只能是一个视图。

另一个需要关注的语法是.padding(),被称为布局修饰符——它通过在文本视图周围放置 20 个点的填充(这是当我们不选择值时的默认数量)来修改文本视图的布局。许多不同的修饰符被分组到不同的类别中,例如文本修饰符、图像修饰符、列表修饰符等等。

在 Xcode 中点击右上角的加号按钮,然后选择修饰符选项卡,尝试一下并实验它们。当你开始构建项目时,你会很快了解许多不同的修饰符。

现在,让我们将注意力转向这些视图在屏幕上的组织布局。

堆叠

记得我曾经说过,some View协议有一个要求,那就是在运行代码时只返回一个视图。这在非常简单的应用中是可行的,但更多的时候,我们需要返回多个视图——实际上可能需要在屏幕上显示多个视图供用户交互,例如按钮、文本框、一些图片、文本等等。我们需要在屏幕上垂直和水平地组织这些视图,以及在z轴上(将视图放置在彼此之上)。

要实现这一点,SwiftUI 为我们提供了垂直、水平和 z 轴堆叠,或者简称为VStackHStackZStack。这些是容器视图,可以在其中容纳 10 个视图。容器内的视图被称为子视图。现在让我们逐一看看。

VStack

一个Text视图,并在用户的屏幕上显示Hello World

但如果我们想从body计算属性中返回多个视图怎么办?也许我们想在屏幕上有一个Button和一个Text视图,就像这个代码示例一样:

struct ContentView: View {
    @State var myText = ""
    @State var changeText = false
    var body: some View {
        Text(myText)
            .padding()
        Button("Button") {
            changeText.toggle()
            if changeText {
                myText = "Hello SwiftUI!"
            } else {
                myText = "Hello World!"
            }
        }
    }
}

如果我们按下Command + B来构建这段代码,它将干净且无错误地构建。

但尽管这段代码没有错误,预览中没有任何显示,所以当我们按下播放按钮运行它时,它不会做任何事情。代码不会做任何事情,因为body计算属性中有两个视图:一个Text视图和一个Button视图。这违反了View协议,该协议只希望返回some View(单数),而不是some Views(复数)。

现在,看看对它进行了一些微小更改的相同代码:

struct ContentView: View {
    @State var myText = ""
    @State var changeText = false

    var body: some View {
        VStack {
            Text(myText)
                .padding()
          Button("Button") {
                changeText.toggle()
                if changeText {
                    myText = "Hello SwiftUI!"
                } else {
                    myText = "Hello World!"
                }
            }
        }
    }
}

我已经将所有代码放入了VStack中。现在,当我们运行它时,一切如预期般工作,两个视图可以在body计算属性内共存,没有任何问题。如果我们按下按钮,文本将根据changeText属性中的值而改变:

图 1.5:VStack

图 1.5:VStack

VStack可以容纳 10 个子视图,并且仍然被认为只返回一个视图本身,因此满足some View协议。如果你需要超过 10 个子视图,你可以在彼此内部嵌套VStack以添加更多视图。

如你所想,VStack 将其所有子视图垂直堆叠,但你也可以在 VStack 初始化器中设置可选的对齐和间距。

对齐

默认情况下,VStack 中的所有内容都是居中对齐的,但如果你想让所有子视图都对齐到前导边缘或尾随边缘,你可以使用 alignment 参数,如下所示:

struct ContentView: View {
   var body: some View {
        VStack(alignment: .leading) {
      Text("Hi, I'm child one in this vertical stack")
      Text("Hi, I'm child two in this vertical stack")
      Text("Hi, I'm child three in this vertical stack")
      Text("Hi, I'm child four in this vertical stack, I'm the         best")
        }
    }}

所有子视图现在都在 VStack 内部对齐到前导边缘:

图 1.6:前导对齐

图 1.6:前导对齐

你还可以将视图对齐到尾随边缘或中心。为此,我们使用点符号来访问那些其他 enumeration 值:.leading.trailing.center 是可用于对齐的选项。

间距

VStack 内部的另一个参数是 spacing 选项。这将使所有子视图之间有一定的空间:

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Hi, I'm child one in this vertical stack")
            Text("Hi, I'm child two in this vertical stack")
            Text("Hi, I'm child three in this vertical stack")
            Text("Hi, I'm child four in this vertical stack,               I'm the best")
        }
    }
}

代码在每个子视图之间放置了 10 个点的空间,正如我们在这里可以看到的:

图 1.7:VStack 间距

图 1.7:VStack 间距

这就是 VStack 的工作方式;让我们继续,现在看看 HStack

HStack

VStack 相比,HStack 水平显示其子视图。以下是一个例子:

struct ContentView: View {
    var body: some View {
        HStack() {
            Text("0")
            Text("1")
            Text("2")
            Text("3")
            Text("4")
            Text("5")
            Text("6")
            Text("7")
            Text("8")
            Text("9")
        }.font(.headline)
    }
}

所有视图现在都是水平堆叠的:

图 1.8:HStack

图 1.8:HStack

font 修饰符添加到整个父堆叠将影响其中所有的子视图,但要影响单个子视图,你需要直接将其放置在其上。以下是一个示例:

struct ContentView: View {
    var body: some View {
        HStack() {
            Text("0")
            Text("1")
            Text("2")
            Text("3")
            Text("4").font(.title)
            Text("5")
            Text("6")
            Text("7")
            Text("8")
            Text("9")
        }.font(.headline)
    }
}

因此,Text 视图的编号 4 现在已经被修改为具有更大的字体:

图 1.9:修改子视图

图 1.9:修改子视图

让我们看看另一个重要的堆叠,即 ZStack

ZStack

ZStack 是一个将覆盖其子视图的堆叠,一个叠在另一个上面。使用这个堆叠,我们可以创建一个视图层次结构,其中堆叠中的第一个视图将被放置在底部,后续视图将按顺序堆叠在彼此的顶部。看看这个例子:

struct ContentView: View {
    var body: some View {
        ZStack() {
            Image(systemName: "rectangle.inset.filled.and.              person.filled")
                .renderingMode(.original)
                .resizable()
                .frame(width: 350, height: 250)

            Text("SwiftUI")
                .font(.system(size: 50))
                .foregroundColor(.yellow)
                .padding(.trailing, 80)
        }
    }
}

代码包含两个视图:

  • 第一个是 Image 视图,它接受你已导入到资产目录中的图像,并使得在屏幕上显示图像成为可能。通过使用 systemName 参数,我们可以从苹果预制的图像库中选择一个系统图像,这些图像来自许多不同的类别。

要在你的项目中使用系统图像,请下载名为 systemNameImage 参数。在我的例子中,我使用了一个名为 rectangle.inset.filled.and.person.filled 的图像,并将其放置在 ZStack 的开头;任何添加在其下方的视图都将放置在该图像的上方。

  • 第二个视图是一个 Text 视图,通过 ZStack 放置在系统图像的顶部。同样,因为 Text 视图的代码是在创建 Image 视图的代码下方添加的,所以当我们运行应用时,它会被放置在 Image 视图的上方。

然后,我使用了一点点填充,我们可以将文本放置在我们想要的位置。你也可以使用offset修饰符将视图放置在屏幕上的任何位置。

你可以在下面的图中看到代码的结果:

图 1.10:ZStack

图 1.10:ZStack

在这段代码中,还有三个修饰符,当我们开始构建项目时,我们将更深入地研究它们——那就是renderingModeresizableframe修饰符。它们在这里被使用,因为我们需要正确地渲染和调整图像的大小。

组合栈

现在我们已经看到了如何使用三个栈来显示多个视图并将它们放置在屏幕上的任何位置,让我们看看一个示例,它结合了所有三个栈以及它们内部的子视图,以产生一个多样化的布局:

struct ContentView: View {
    var body: some View {
        VStack {
            ZStack() {
                Image(systemName: "cloud.moon.rain.fill")
                    .foregroundColor(Color(.systemOrange))
                    .font(.system (size: 150))
                Text("Stormy").bold()
                    .font(.system(size: 30))
                    .offset(x: -15, y: -5)
                    .foregroundColor(.indigo)
            }
            HStack() {
                Image(systemName: "tornado")
                    .foregroundColor(Color(.systemBlue))
                    .font(.system (size: 50))

                VStack(alignment: .leading) {
                    Text("Be prepared for anything")
                        .font(.system(size: 25))
                        .fontWeight(.bold)

                    Text("With the Stormy Weather app")
                        .font(.system(size: 16))
                }
            }
        }
    }
}

让我们分解这段代码,以便清楚地了解我们在做什么。

首先是VStack。这将是我们主要的栈,将包含我们所有的代码。以这种方式使用VStack意味着我们可以在其中挤压 10 个子视图,但在这个例子中我们只需要放置几个视图。

接下来是ZStack。内部包含其两个子视图——一个Text视图和一个Image视图。由于Image视图在代码中排在前面,所以Text视图被放置在它上面。

注意ZStack中的每个子视图都有自己的缩进修饰符集;这些修饰符用于使用颜色和大小来样式化这些子视图,并在屏幕上定位它们。

下一个栈是HStack。记住,这个栈是水平排列其子元素的,并且它有两个子视图,包括一个Image视图和一个VStack。注意我们如何像这里这样在栈中嵌套栈,这个VStack就在HStack里面。HStack将其第一个子视图放置在左侧——那就是龙卷风图像——然后将其第二个子视图放置在右侧——那就是VStack。如果我们现在查看VStack内部,它有自己的两个子视图。它们将垂直排列,较小的文本在底部。

如果一开始这种栈的嵌套看起来有点混乱,请不要担心;当我们开始构建项目时,你将训练你的大脑以层次结构思考和观察,这对你来说将变得非常自然。

通过嵌套不同的栈,我们可以制作出各种有趣的布局场景。以下是我们的示例结果:

图 1.11:组合和嵌套栈

图 1.11:组合和嵌套栈

栈对于帮助组织和在屏幕上布局视图非常出色,但我们可以使用另一个提供更多灵活性的容器视图。那就是GeometryReader视图。然而,我们将在本章末尾讨论它。

现在,让我们看看一个Spacer视图——这是一个帮助在 UI 设计中布局间距的视图。

空间视图

一个 HStack,一个 Spacer 视图会水平扩展到堆栈允许的最大程度,在堆栈尺寸的范围内移开兄弟视图。

这是 Spacer 初始化器:Spacer(minLength: CGFloat)。这个初始化器创建一个灵活的空间。minLength 参数设置空间可以取的最小尺寸。如果参数留空,则最小长度为零。

这里是一个在 HStack 中使用 Spacer 视图的例子:

struct ContentView: View {
    var body: some View {
        HStack {
            Text("Hello")
            Spacer()
            Text("World")
        }.padding()
    }
}

这将创建一个带有 Spacer 视图的水平堆栈,它会占据所有剩余空间,从而使两段文本被推到容器的边缘:

图 1.12:分隔符

图 1.12:分隔符

Spacer 视图也可以用在 VStack 中,并且它会垂直扩展并推离子视图,而不是水平扩展。不过,Spacer 视图在 ZStack 中不会做任何事情,因为那个堆栈处理视图在 z-轴上的深度,从前往后。

Spacer 通过在它们之间创建空间来分隔视图时,另一个称为 Divider 视图的物体通过一条细线垂直或水平地分隔视图。

分隔符视图

分隔符视图是一个视觉元素,一条分隔线,可以用来水平或垂直地分隔内容。你还可以更改分隔线的厚度。让我们看看一些例子。

水平

以下代码创建了一个水平分隔符:

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Hi, I'm child one in this vertical stack")
            Text("Hi, I'm child two in this vertical stack")
            Text("Hi, I'm child three in this vertical stack")
            Text("Hi, I'm child four in this vertical stack")
            Divider().background(Color.black)

            Text("Hi, I'm child five in this vertical stack")
            Text("Hi, I'm child six in this vertical stack")
            Text("Hi, I'm child seven in this vertical stack")
            Text("Hi, I'm child eight in this vertical stack")
        }.padding()
    }
}

如你所见,我们添加了一条水平分隔线,并使用 background 修饰符将其颜色设置为黑色:

图 1.13:分隔符

图 1.13:分隔符

垂直

如果我们想将水平线改为垂直线,那么我们可以使用较小的数字传递 width 参数,并使用较大的数字传递 height 参数,如下例所示:

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Hi, I'm child one in this vertical stack")
            Text("Hi, I'm child two in this vertical stack")
            Text("Hi, I'm child three in this vertical stack")
            Text("Hi, I'm child four in this vertical stack")

            Divider().frame(height: 200).frame(width:               3).background(Color.blue)
            Divider().frame(height:200).frame(width:               3).background(Color.blue).offset(x: 300, y: 0)

            Text("Hi, I'm child five in this vertical stack")
            Text("Hi, I'm child six in this vertical stack")
            Text("Hi, I'm child seven in this vertical stack")
            Text("Hi, I'm child eight in this vertical stack")
        }.padding()
    }      
}

使用 offset 修饰符可以让我们将线条放置在屏幕上的任何位置,或者任何视图上。以下是结果:

图 1.14:垂直分隔符视图

图 1.14:垂直分隔符视图

厚度

我们可以使用 frame 修饰符更改分隔线的厚度,如下所示:

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Hi, I'm child one in this vertical stack")
            Text("Hi, I'm child two in this vertical stack")
            Text("Hi, I'm child three in this vertical stack")
            Text("Hi, I'm child four in this vertical stack")
            Divider().frame(height: 20).frame(width: 300).              background(Color.blue)

            Text("Hi, I'm child five in this vertical stack")
            Text("Hi, I'm child six in this vertical stack")
            Text("Hi, I'm child seven in this vertical stack")
            Text("Hi, I'm child eight in this vertical stack")
        }.padding()
    }
}

然后,通过使用 frame 修饰符的 heightwidth 参数,我们可以更改 Divider 视图的尺寸,这样我们就可以制作出我们想要的任意长和宽的线条。我们示例的结果如下所示:

图 1.15:分隔符厚度

图 1.15:分隔符厚度

这基本上就是我们可以用 Divider 视图做的所有配置。现在让我们看看 SwiftUI 中最常用的修饰符之一——padding 修饰符。

填充修饰符

你可能已经注意到,我们已经在没有真正解释的情况下大量使用了 padding modifier

每个视图都有自己的尺寸和它在屏幕上占据的空间。例如,我们可以将两个视图并排放置,它们将非常接近,仅由几个点分隔。

点和像素是衡量屏幕上视图大小的不同方式。

用于指定文本和其他 UI 元素的大小,并且它们与设备屏幕的分辨率无关。这意味着在任何设备上,点的大小始终相同,无论屏幕的分辨率如何。

另一方面,像素是构成 iPhone(或任何其他设备)屏幕的单独点。像素用于衡量设备屏幕的物理分辨率。

当您向一个视图添加 padding 修饰符时,默认情况下会在该视图周围添加一定量的空白空间,并且该空间以点为单位衡量。默认情况下,padding 修饰符会在视图周围添加八点的空白空间,但如果您想更具体地指定填充视图的空间量,则可以传递一个值(一个整数)作为自定义填充量。让我们看一个例子:

struct ContentView: View {
    var body: some View {
        VStack{
            VStack(alignment: .leading, spacing: 10) {
                Text("There is padding all around this view")
                Text("There is padding all around this view")
                Text("There is padding all around this view")
                Text("There is padding all around this view")

            }.background(Color.yellow)
                .padding(30)
                .background(Color.red)
        }
    }
}

在此代码中,VStack 内部有四个 Text 视图。在 VStack 的闭合括号末尾是一个对 background 修饰符的调用,这将使背景变为黄色,以便您可以看到 padding 修饰符的作用。接下来,我添加了 padding 修饰符并传递了 30 点,这将均匀地应用于 VStack。最后,我再次使用 background 修饰符将填充涂成红色,这样您可以直接看到填充。在示例中,填充将看起来像围绕 VStack 的红色框架,如下面的截图所示:

图 1.16:填充

图 1.16:填充

让我们看看其他一些 padding 选项。修饰符允许我们选择预定义的枚举值,如果我们只想填充一边,如下面的代码所示:

struct ContentView: View {
    var body: some View {
        VStack{
            VStack(alignment: .leading, spacing: 10) {
                Text("The leading edge has been padded")
            }.padding(.leading, 75)
        }
    }
} 

此代码仅通过 75 点的空间填充每个 Text 视图的 leading 边缘:

图 1.17:填充选项

图 1.17:填充选项

我们也可以通过输入一个点作为 alignment 参数并从许多不同的选项中选择来选择其他填充选项,包括 trailingtopbottomhorizontalinfinity 等。

查看前面的代码,注意 padding 修饰符的位置;它已经被放在 VStack 的闭合括号上。当以这种方式放置时,VStack 内部的所有子视图都会应用填充,但如果我们要单独填充子视图,我们可以通过直接在它们上放置修饰符来实现:

struct ContentView: View {
    var body: some View {
        VStack{
            VStack(alignment: .leading, spacing: 10) {
                Text("I'm padded on the leading edge").padding                   (.leading, 75)
                Text("I'm padded on the trailing edge").padding                   (.trailing, 75)
                Text("I'm padded on the leading edge").padding                   (.leading, 75)
                Text("I'm padded on the trailing edge").padding                   (.trailing, 75)
                Text("I'm padded on the leading edge").padding                   (.leading, 75)
                Text("I'm padded on the trailing edge").padding                   (.trailing, 75)
                Text("I'm padded on the leading edge").padding                   (.leading, 75)
                Text("I'm padded on the trailing edge").padding                   (.trailing, 75)
            }
        }
    }
}

如您所见,您可以单独为子视图添加修饰符,根据您的布局需求进行样式化。结果如下:

图 1.18:填充子视图

图 1.18:填充子视图

每个子视图现在都有自己的填充,可以是 leadingtrailing,这会改变其在屏幕上的位置。

让我们通过查看闭包来结束这一章,闭包本质上是一个没有名称的函数,然后是另一个提供比我们之前查看的其他堆栈更多灵活性的容器视图——GeometryReader 视图。

闭包

这里是闭包的简单定义:闭包是一个没有名称的函数。记住,函数是一个代码块,当它被调用时,会运行其体内的任何代码语句。

但让我们先来详细定义一下闭包:闭包是一个自包含的代码块,它可以被传递并在稍后执行。

闭包并不完全是函数,但它们很相似,有一些关键的区别:

  • 闭包可以作为变量存储,并作为参数传递给函数

  • 闭包可以捕获并存储它们定义的上下文中的任何变量或常量的引用,这使得它们能够在调用之间保持状态并保留数据。

  • 闭包没有像函数那样的名称

在 SwiftUI 中,闭包通常被用作响应用户输入或其他事件的方式。例如,你可能将闭包用作按钮的动作,或者提供一段代码块,在视图出现或消失时执行。

这里是一个闭包被用作 SwiftUI 中按钮动作的例子:

Button(action: {
    // this block of code will be run when the button is       clicked
    print("Button was clicked!")
}) {
    Text("Button")
}

在这个例子中,闭包使用 { } 语法定义,并作为 action 参数传递给 Button 视图。是的,没错,Button 的动作就是一个闭包,当按钮被按下时,闭包内部的代码将被执行。

闭包还可以用于为 SwiftUI 中的视图提供自定义行为。例如,你可能使用闭包在视图出现或消失时执行一些自定义动画:

struct MyView: View {
    var body: some View {
        Text("Hello, SwiftUI!")
            .onAppear {
                // this block of code will run when the view                   appears
                print("The view appeared!")
            }
    }
}

在这个例子中,onAppear 修改器被调用在 Text 视图上,并传递了一个当视图出现在屏幕上时将被执行的闭包。

我们还有尾随闭包。尾随闭包是一个写在传递给它的函数或方法之后的闭包。闭包被称为“尾随”因为它位于函数或方法之后。

这里是一个接受闭包作为参数的函数的例子:

func doSomething(completion: () -> Void) {
    // Do some work!
    print("Work complete")
    completion()
}

你可以这样调用这个函数并传递一个闭包作为参数:

doSomething {
    // this block of code will run when the "completion"       closure is called
    print("completion closure called!")
}

在这个例子中,闭包写在 doSomething 函数之后,因此是一个尾随闭包。在 SwiftUI 中,你可以使用尾随闭包为视图提供自定义行为。例如,你可能使用尾随闭包在视图出现或消失时执行一些自定义动画:

Text("Hello, World!")
    .onAppear {
        // this block of code will be executed when the view           appears
        print("View appeared!")
    }

在这个例子中,onAppear 方法被调用在 Text 视图上,并传递了一个当视图出现在屏幕上时将被执行的尾随闭包。

如果你现在还没有完全理解闭包的工作原理,不要担心;它们并不像看起来那么复杂,随着我们继续阅读本书,你会更好地理解它们。现在,让我们继续到 GeometryReader

GeometryReader

GeometryReader 将包含我们正在处理的视图的位置和大小,然后我们可以使用 GeometryReader 代理返回的值来更改或放置该视图。

使用这些值,我们可以使子视图根据设备大小和位置动态更新其位置,当方向更改为横屏或竖屏时。这一切将在我们看到示例时变得更加清晰。

让我们看看如何创建 GeometryReader。以下是用作创建 GeometryReader 视图的初始化器:

GeometryReader(content: _)

content 参数是一个闭包,它接收一个包含视图位置和尺寸的几何代理值。

要检索这些值,我们使用以下属性和方法:

  • size 属性将返回 GeometryReader 视图的宽度和高度

  • safeAreaInsets 属性将返回一个包含安全区域边距的 EdgeInsets

  • frame(in:) 方法返回 GeometryReader 视图的位置和大小

这是创建一个空的 GeometryReader 的代码:

struct ContentView: View {
    var body: some View {
           GeometryReader {_ in
            //empty geometry reader
        }.background(Color.yellow)
     }
  }

这是我们运行代码时看到的结果。注意它如何将自己推出去以占据屏幕上的所有空间;黄色背景显示了整个屏幕上这个空白的 GeometryReader 的所有区域。

图 1.19:GeometryReader

图 1.19:GeometryReader

GeometryReader 的默认行为是将其子视图对齐在左上角并将它们堆叠在一起。在下一个示例中,代码将三个视图放置在 GeometryReader 内部:

struct ContentView: View {
    var body: some View {
        GeometryReader {_ in
             Image(systemName: "tornado")
             Image(systemName: "tornado")
             Image(systemName: "tornado")
               }.background(Color.yellow)
            .font(.largeTitle)
    }
}

当我们运行此代码时,请注意只有一张龙卷风图像是可见的:

图 1.20:GeometryReader 默认子视图对齐

图 1.20:GeometryReader 默认子视图对齐

实际上,这个例子中有三个龙卷风图像,但你只能看到其中一个,因为默认行为是将它们堆叠在屏幕左上角。

我们显然不希望所有子视图都堆叠在一起,因此我们将探索以下概念以真正利用 GeometryReader

  • 为适应设备旋转而调整视图大小

  • 在屏幕上的任何位置定位视图

  • 以全局和局部空间为依据读取视图的位置

我们现在就来查看这些。

调整视图大小

让我们先看看用来访问 GeometryReader 的大小属性……恰如其名,size 属性。这将返回 GeometryReader 视图的宽度和高度。

这是一个在 GeometryReader 视图内添加图像并查看设备旋转时它如何调整其大小的示例:

struct ContentView: View {
    var body: some View          
 GeometryReader { geometryProxy in
            Image("swiftui_icon")
                .resizable()
                .scaledToFit()
                .frame(width: geometryProxy.size.width / 2,                   height: geometryProxy.size.height / 4)
                .background(Color.gray)
        }
    }
}

下图显示了竖屏模式下图像的大小:

图 1.21:GeometryReader 的大小属性(纵向)

图 1.21:GeometryReader 的大小属性(纵向)

在竖屏模式下,图像更大,但当设备旋转到横屏时,图像会缩小以适应屏幕变化,如下所示:

图 1.22:GeometryReader 大小属性(横向)

图 1.22:GeometryReader 大小属性(横向)

在这个例子中,我们在GeometryReader视图中添加了一个Image视图。我将Proxy参数定义为geometryProxy,但你可以将其命名为任何你想要的。geometryProxy包含有关GeometryReader的尺寸和位置的信息。使用此Proxy对象,当旋转设备时,图像的大小会改变。图像的宽度将是容器宽度的一半,高度将是容器高度的四分之一。

使用geometryProxy.size让我们可以访问GeometryReader的高度和宽度。当设备旋转时,Image视图将适应。我还使用了scaledToFit修饰符,以便我们可以保持图像的正确宽高比。

我将Image视图的背景设置为灰色,这样你就可以看到在纵向和横向模式下都可用空间。当处于横向模式时,Image视图周围有更多的可用空间,因为它会缩小。

还要注意,图像被定位在GeometryReader视图的左上角。同样,这也是其子视图的默认位置;它们将简单地堆叠在那个区域。

接下来,我们将看看如何在GeometryReader容器内将那些子视图放置在我们想要的任何位置。

定位视图

我们已经看到GeometryReader如何动态地改变视图的大小,当它旋转时,但它也可以在内部定位视图。定位信息由geometryProxy闭包返回,我们可以通过size属性将此信息传递到position修饰符的xy参数。以下是一个示例:

 struct ContentView: View {
    var body: some View {

GeometryReader { geometryProxy in
            //top right position
            VStack {
                Image(systemName: "tornado")
                .imageScale(.large)
            Text("Top Right")
                .font(.title)
            }.position(x: geometryProxy.size.width - 80, y:               geometryProxy.size.height / 40)

            //bottom left position
            VStack {
                Image(systemName: "tornado")
                .imageScale(.large)

            Text("Bottom Left")
                .font(.title)
            }.position(x: geometryProxy.size.width - 300,y:               geometryProxy.size.height - 40)
        }.background(Color.accentColor)
        .foregroundColor(.white)
    }
}

这里的代码在GeometryReader中有两个VStack,每个VStack在其闭合括号上都有position修饰符,所以VStack内的所有内容都将根据position修饰符的xy参数的值进行定位。

运行此代码的结果是ImageText视图的放置根据size.widthsize.height值,如图所示:

图 1.23:GeometryReader 定位视图

图 1.23:GeometryReader 定位视图

让我们继续使用GeometryReader并看看我们如何读取其位置值。

读取位置

如果我们需要以coordinate位置来获取GeometryReader视图的位置,我们再次可以使用geometryProxy对象,并通过将其信息传递到frame()方法中。

SwiftUI 中有两个坐标空间:

  • 全局空间:整个屏幕的坐标

  • 局部空间:单个视图的坐标

当读取GeometryReader视图的局部坐标空间值时,我们总会看到xy的值为0,0。那是因为GeometryReader始终从该位置开始,即屏幕的左上角0,0

因此,为了获取视图相对于屏幕上的位置,我们需要使用全局坐标。以下是一个获取并显示 GeometryReader 视图局部和全局值的示例:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometryProxy in
            VStack {
                Image("SwiftUIIcon")
                    .resizable()
                    .scaledToFit()
                Text("Global").font(.title)
                Text("X, Y \(geometryProxy.frame(in:                   CoordinateSpace.global).origin.x, specifier:                   "(%.f,") \(geometryProxy.frame(in: .global).                  origin.y, specifier: "%.f)")")
                Text("Local").font(.title)
                Text("X, Y  \(geometryProxy.frame(in: .local).                  origin.x, specifier: "(%.f") \(geometryProxy.                  frame(in: .local).origin.y, specifier:                   "%.f)")")
            }
        }.frame(height: 250)
    }
}

注意

注意,这些值正在使用格式说明符进行格式化:%.f。此格式说明符将截断一些小数位,以显示更少的零。

运行代码将在设备处于纵向模式时显示视图的全局和局部空间的 xy 坐标值,如下所示:

图 1.24:读取位置(纵向)

图 1.24:读取位置(纵向)

将设备旋转到横向模式会改变全局值,以反映 ImageText 视图的新位置,如下所示:

图 1.25:读取位置(横向)

图 1.25:读取位置(横向)

本例中的代码演示了全局坐标空间和局部坐标空间之间的区别。局部坐标空间的值始终返回 0,0,因为那是 GeometryReader 的起始位置,但全局坐标空间的值是整个屏幕内 geometryProxy 帧的起点。因此,全局值会随着设备的旋转而改变,因为视图的位置已经改变。局部值不会改变,因为它们反映了屏幕的左上角,即 0,0

SwiftUI 还提供了获取 GeometryReader 帧内全局和局部空间的最低、中间和最大坐标位置的性质:

Text("minX: \(geometryProxy.frame(in: .local).minX))")
Text("minX: \(geometryProxy.frame(in: .local).midX))")
Text("maxX: \(geometryProxy.frame(in: .local).maxX))")
Text("minX: \(geometryProxy.frame(in: .global).minY))")
Text("minX: \(geometryProxy.frame(in: .global).midY))")
Text("maxX: \(geometryProxy.frame(in: .global).maxY))")

我们使用点语法,在 frame 方法的最后使用这些属性。以下是一个示例:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometryProxy in
            VStack() {
                Spacer()
                Text("Local Values").font(.title2).bold()
                HStack() {
                    Text("minX: \(Int(geometryProxy.frame(in:                       .local).minX))")
                    Spacer()
                    Text("midX: \(Int(geometryProxy.frame(in:                       .local).midX))")
                    Spacer()
                    Text("maxX: \(Int(geometryProxy.frame(in:                       .local).maxX))")
                }

                Divider().background(Color.black)

                Text("Global Values").font(.title2).bold()
                HStack() {
                    Text("minX: \(Int(geometryProxy.frame(in:                       .global).minX))")
                    Spacer()
                    Text("midX: \(Int(geometryProxy.frame(in:                       .global).midX))")
                    Spacer()
                    Text("maxX: \(Int(geometryProxy.frame(in:                       .global).maxX))")
                }
                Spacer()

            }.padding(.horizontal)
        }
    }
}

此代码将返回 GeometryReader 帧的最小、中间和最大 xy 坐标,并在纵向模式下在屏幕上显示局部和全局空间的坐标,如下所示:

图 1.26:使用 min、mid 和 max 属性(纵向)

图 1.26:使用 min、mid 和 max 属性(纵向)

当设备旋转时,这些值将调整为新值,以反映屏幕方向的变化,如下所示:

图 1.27:使用 min、mid 和 max 属性(横向)

图 1.27:使用 min、mid 和 max 属性(横向)

你刚刚学到的 SwiftUI 结构将是我们接下来要编写的程序的基础。由于在一个章节中列出所有结构可能会太多,难以消化,我将在整本书的不同项目中介绍新的结构。

摘要

总结本章所学内容,我们介绍了 SwiftUI 并看到了两种编程范式——命令式和声明式之间的区别。之后,我们探索了 Xcode 的界面。然后,我们涵盖了状态的重要功能,最后查看 SwiftUI 的构建块——这些是你在整本书中都会用到的基本概念,也是创建 SwiftUI 中美丽动画的必要第一步。

在下一章中,我们将探讨动画,它们是如何工作的,以及可以动画化的属性种类。

第二章:理解使用 SwiftUI 的动画

在第一章中,我们介绍了在开始构建项目时我们将看到的许多 SwiftUI 基础知识。在本章中,我们将探讨动画的工作原理,包括时间曲线和可动画属性,这将为我们将要构建的项目奠定基础。

以下是我们将要探讨的关键主题:

  • 什么是动画?

  • 理解时间曲线

  • 理解动画类型

  • 触发动画

  • 探索可动画属性

技术要求

您可以在 GitHub 的Chapter 2文件夹中找到本章的代码:github.com/PacktPublishing/Animating-SwiftUI-Applications

什么是动画?

让我们考虑这本书对动画的定义。动画是一系列静态图像以快速连续的方式显示,以产生运动错觉。这些图像,或帧,通常以每秒 24 或 30 帧的速率从开始到结束显示,这足以产生连续运动的错觉。这些可以使用各种技术创建,包括手绘、计算机生成和定格动画。

从这个定义来看,我们可以看到动画有一个起点和终点,中间的图像都是略有不同的;当播放时,我们的眼睛无法分辨出单个图像,这意味着我们感知到运动或动画。

在 SwiftUI 中,我们必须定义起点(动画开始的地方)和终点(动画结束的地方)。然而,当我们编写代码时,我们实际上并没有在两个终点之间放置一大堆静态图像(尽管我们可以这样做);我们通常使用单个图像,然后动画化该图像的属性,例如其在屏幕上的位置、其不透明度或其颜色。

除了图像之外,我们还可以通过改变它们的色调或形状的圆角来动画化 RGB 颜色,如果我们绘制一个形状,我们可以动画化其单个线条(路径)或周围的描边。

它的工作原理是这样的:如果我们想让一个矩形从 iPhone 或 iPad 屏幕的左下角移动到右上角,我们可以在代码中(又是那种声明式语法)通过使用animation修饰符来声明它。然后 SwiftUI 为我们施展魔法,移动对象,或者用 SwiftUI 的话说,“过渡”对象从起点到终点,用我们在路上使用的任何值(整数、颜色、不透明度等)填充所有空白。通过遍历所有值来创建平滑流畅运动的过程被称为插值

SwiftUI 为我们很好地填补了这些空白,但它不能为每个视图的每个属性进行动画。只有被认为是“可动画”的属性才能进行动画;例如,视图的颜色、不透明度、旋转、大小、位置、圆角和描边。几乎所有具有数值的属性都是可动画的。

SwiftUI 包含了基本的动画,默认或自定义缓动或时间曲线(时间曲线指的是动画开始和结束的速度),以及弹簧动画。弹簧动画具有弹跳效果,可以从轻微的弹跳调整到非常明显的弹跳,类似于橡皮球在地面上弹跳。

你还可以更改许多自定义选项,例如动画的速度、在动画开始前的“等待”时间,以及使动画重复。

让我们继续深入探讨动画时间曲线,看看它们看起来像什么以及它们如何影响动画。

理解时间曲线

动画有所谓的曲线。曲线,更具体地说,是时间曲线,指的是动画开始和结束的速度。

SwiftUI 提供了多种时间曲线供我们在 animation 修饰符中使用。它被称为时间曲线,因为如果你要在图表上绘制动画从开始到结束的每个点的位置,并通过连接这些点绘制一条线,大多数情况下都会形成曲线,就像这个插图所示:

图 2.1:ease 时间曲线

图 2.1:ease 时间曲线

此图显示了三种动画时间曲线:easeInOut 时间曲线,动画开始时速度较慢,然后加速,最后在完全停止前再次减速。

此外,还有一个线性时间曲线。使用此曲线的动画在开始和结束时将以相同的速度进行。如果你要在图表上绘制它,它将是一条直线,如下所示:

图 2.2:线性时间曲线

图 2.2:线性时间曲线

时间曲线并不复杂——我们可以根据自己的需求选择想要的时间曲线。如果你没有指定时间曲线,你将得到默认曲线,即 easeInOut。我们将在我们的项目中使用一些这些 SwiftUI 提供的时间曲线。

在下一节中,我想解释 SwiftUI 中的两种不同类型的动画:隐式和显式。

理解动画类型

在 SwiftUI 中有两种类型的动画:隐式和显式。让我们看看这些动画类型的作用以及两者之间的区别。

一个 animation 修饰符。以下是一个示例:

struct ContentView: View {
    @State private var grow: CGFloat = 1
    var body: some View {
        Circle()
            .frame(width: 100, height: 100)
            .foregroundColor(.blue)
            .onTapGesture {
                self.grow += 0.5
            }
            .scaleEffect(grow)
            .animation(.default, value: grow)
    }
}

在此示例中,我们使用轻触手势来放大圆圈的大小;当轻触时,圆圈将增长到其大小的一半。.animation 修饰符使用默认的动画样式,默认情况下将使用 easeInOut 时间曲线来动画化圆圈。

你也可以使用其他动画样式。例如,在这里,我添加了 spring 样式而不是默认样式:

var body: some View {
        Circle()
••••••••
            .scaleEffect(grow)
            .animation(.spring(dampingFraction: 0.3,blendDuration: 0.5),value: grow)
    }

此样式将使圆圈动画并为其添加弹簧效果。

因此,隐式动画是向您的 SwiftUI 应用程序添加基本动画的一种方便方式,而无需编写任何显式动画代码。动画是通过animation修饰符应用的。

有时,您可能希望从动画中获得更多,而隐式动画可能无法提供您所需的控制程度。在这种情况下,您可以使用显式动画。

一个withAnimation函数。以下是一个示例:

struct ContentView: View {
    @State private var scaleUp: CGFloat = 1
    var body: some View {
        Button(action: {
            //Animate the scale of the view when the button is               tapped
            withAnimation(.interpolatingSpring(stiffness: 60,               damping: 2)) {
                scaleUp *= 1.4
            }
        }) {
            Text("Scale up")
                .scaleEffect(scaleUp)  // explicit animation
        }
    }
}

在这个示例中,点击按钮将使用弹簧动画来动画化文本的缩放。动画的持续时间将由系统的默认动画设置确定,但动画的曲线将通过interpolatingSpring函数进行自定义。

此外,您还可以通过在interpolatingSpring函数中指定duration参数来自定义动画的持续时间。以下是一个示例:

withAnimation(.interpolatingSpring(stiffness: 60, damping: 2, duration: 2.5)) { scaleUp *= 1.5 }

这将使动画持续2.5秒。

因此,使用显式动画与隐式动画的区别在于,它们可以为您提供更多控制动画细节的能力,或者当您想要同时动画化多个属性时;您可以将尽可能多的代码放入withAnimation块中。然而,它们可能比隐式动画更难设置。

您也可以让动画重复播放预设的次数或无限期重复。以下是一个将之前的动画永久重复并自动反转的示例:

withAnimation(.interpolatingSpring(stiffness: 60, damping: 2).repeatForever(autoreverses: true)) {
                    scaleUp *= 1.4
                }

在之前的示例中,我修改了withAnimation函数中的代码,以包含repeatForever选项并将autoreverses参数设置为true。当您运行代码时,文本将以弹簧效果放大,当它完成弹跳(大约 3 秒左右)后,动画将重新开始,无限期重复或直到应用程序停止。

这些就是两种类型的动画;接下来是一个触发动画的方法列表。

触发动画

那么,我们如何触发动画呢?在 SwiftUI 中,有几种方法可以做到这一点,包括使用以下 SwiftUI 修饰符、方法和函数:

  • .animation()修饰符:此修饰符允许您指定视图出现或消失或其状态变化时使用的动画类型。

  • withAnimation()函数:此函数允许您将一个代码块包裹起来,该代码块会改变视图的状态,并且它会自动动画化这些更改。

  • 一个手势:这是一种通过执行诸如点击、拖动或捏合等操作与视图交互的方式。您可以使用手势在视图上执行特定操作时触发动画。

  • 一个计时器:这允许您指定在一定时间内执行动画。您可以使用计时器在特定持续时间内动画化视图状态的更改。

  • onAppear()onDisappear() 修饰符:这些修饰符允许你指定在视图出现或消失时执行的代码。这些修饰符可以在视图出现或消失时触发动画。

  • 按钮和其他控件视图:在 SwiftUI 中的按钮、滑块、选择器或其他控件类型视图可以是动画的触发器。

触发动画的其他方法还有很多,但在这里我们将主要介绍这些方法。你选择哪种方法将取决于你应用的具体需求和想要产生的行为。在接下来的章节中,当我们开始构建项目时,我们将探讨这些不同的触发器。

让我们继续探讨在 SwiftUI 中可以动画化的各种属性。

探索可动画属性

在本节中,我们将探索一些可动画属性。以下是我们要查看的属性列表:

  • 偏移

  • 色调旋转

  • 透明度

  • 缩放

  • 描边

  • 剪裁

  • 圆角半径

让我们更详细地看看它们。

偏移属性

我们将要查看的第一个可动画属性是放置在矩形形状上的 offset 修饰符:

struct Offset_Example: View {
    @State private var moveRight = false
    var body: some View {
       //MARK: - ANIMATE OFFSET
        VStack {
            RoundedRectangle(cornerRadius: 20)
                .foregroundColor(.blue)
                .frame(width: 75, height: 75)
                .offset(x: moveRight ? 150 : 0, y: 350 )
                .animation(Animation.default, value: moveRight)
            Spacer()
            Button("Animate") {
                moveRight.toggle()
            }.font(.title2)
        }
    }
}

将这段代码放入你的 ContentView 文件后,你的预览将看起来像 图 2**.3。当你按下 动画 按钮时,蓝色矩形将向右移动,当你再次按下它时,它将返回到其原始起始位置。

图 2.3:动画偏移

图 2.3:动画偏移

这就是代码的工作原理。当 moveRight 变量的值切换或更改为 true,并且 offset 修饰符的 x 参数中有一个三元运算符时。

三元运算符是一个接受布尔变量并检查其是否为 truefalse 的运算符。如果变量为 true,则使用冒号左侧的值,但如果变量为 false,则使用冒号右侧的值。这使得它类似于 if 语句,但不同之处在于 if 语句可以检查多个条件。

因此,如果 moveRighttrue,则圆角矩形将放置在 150 点的右侧;否则,如果为 false,它将保持在原地(0 值表示不执行任何操作)。animation 修饰符也会捕捉到任何变化,因为它在 value 参数中包含了 moveRight 变量。这个 value 参数接受你用于动画的变量。然后 animation 修饰符将在起始值和结束值之间进行插值,并平滑地移动对象,创建一个流畅的动画。

这里是真正看到animation修饰符如何工作的方法。如果您在代码中注释掉animation语句并按下按钮,您将看到对象仍然向右移动150点,但它瞬间完成;现在没有在屏幕上滑动的效果;对象只是出现在其新的位置,向右150点。为了创建平滑流畅的动画,我们需要那个animation修饰符及其背后的插值魔法。这也是为什么我们在 SwiftUI 中使用比在 UIKit 中编码动画更少的代码的原因之一;许多繁重的工作已经在 SwiftUI 的后台为我们完成了。

这是一个通过改变offset修饰符中x参数的数值来将对象从一个点动画到另一个点的示例。让我们看看另一个可动画属性:HueRotation

色调旋转

色调旋转是一种可以应用于视图和其他组件的颜色效果。它是一个修饰符,允许您通过从其色调值中添加或减去一个固定角度来调整颜色的色调。您可以使用色调旋转创建一系列相关的颜色。

修饰符有一个angle参数,它接受弧度或度数的值。这个值基于一个圆,即 360 度,代表我们所能想到的所有颜色的轮盘。

让我们看看一个 Xcode 示例:

struct Hue_Rotation_Example: View {
    @State private var hueRotate = false
        var body: some View {
        //MARK: - ANIMATE HUE ROTATION
        VStack(spacing: 20) {
            Text("ANIMATE HUE ").font(.title2).bold()
            // rotate the colors and stop halfway around the               color wheel
            RoundedRectangle(cornerRadius: 25)
                .frame(width: 200, height: 200)
                .foregroundColor(.red)
                .hueRotation(Angle.degrees(hueRotate ? 180 :                   0))
                .animation(.easeInOut(duration: 2), value:                   hueRotate)
            // rotate the colors around the color wheel one               full revolution (360 degrees)
            Divider().background(Color.black)
            Text("ANIMATE HUE WITH GRADIENT").font(.title2).              bold()
            AngularGradient(gradient: Gradient(colors: [Color.              red, Color.blue]), center: .center)
                .hueRotation(Angle.degrees(hueRotate ? 360 :                   0))
                .animation(.easeInOut(duration: 2), value:                   hueRotate)
                .mask(Circle())
                .frame(width: 200, height: 200)
            Button("Animate") {
                hueRotate.toggle()
            }
            .font(.title)
        }
    }
}

当您将代码添加到 Xcode 中时,您的预览将看起来像图 2**.4

图 2.4:色调旋转动画

图 2.4:色调旋转动画

在这个示例中,我创建了两个对象:一个圆角矩形和一个角度渐变圆形。

在圆角矩形中,我使用三元运算符来检查hueRotate变量是否为true。当通过按下动画按钮变为true时,使用三元运算符内部的冒号左侧的值,即 180 度。然后动画开始通过颜色光谱,并在中途停止以显示该颜色。

注意在时间曲线之后立即使用duration函数。这个函数允许我们设置动画的持续时间;我们希望它快速发生,还是希望动画在更长的时间内发生?它有一个参数,那就是我们希望动画完成所需的时间;为此,我们使用一个整数值。我将值设置为2,这样可以让事情稍微慢一些,使动画需要 2 秒钟来完成。

查看角度渐变示例,我使用的是360度的值。当我们按下动画按钮时,代码会通过整个颜色轮盘进行动画,并在开始的地方停止(360 度是圆的一圈),从而显示原始颜色。

查看按钮体内的hueRotate变量,我们有两种启动动画的方法。第一种是明确地将hueRotate设置为true,如下所示:

hueRotate = true

或者,通过使用toggle方法,就像我们在代码中所做的那样:

hueRotate.toggle()

这两种启动动画的方式之间的区别在于,首先,动画开始并结束,但按下后续按钮时它永远不会反转。如果您希望动画开始并结束,并在下一个按钮按下时反转,请使用 toggle 方法。

另一个非常有趣的现象是,我们可以使形状和其他物体的颜色动起来,也可以使构成图像的颜色动起来,正如我们将在即将到来的项目中看到的那样。

您还可以将 hueRotation() 修饰符与其他修饰符(如 brightness()saturation())结合使用,以创建一些复杂且有趣的颜色调整。

让我们继续看看可以动画化的不同属性,以及一个非常常见的属性,opacity

不透明度

使用 opacity 修饰符使视图出现和消失。当我们给不透明度添加动画时,从显示到隐藏的过渡是插值过的,所以它平滑地淡入淡出。

下面是一个向动画添加不透明度的例子:

struct Opacity__Example: View {
    @State private var appear = true
    var body: some View {
//MARK: - ANIMATE OPACITY
        VStack{
            Text("Appear/Disappear")
                .font(.title).bold()
            Circle()
                .foregroundColor(.purple)
                .opacity(appear ? 1 : 0)
                .animation(.easeIn, value: appear)
                .frame(height: 175)
            Button("Animate") {
                appear.toggle()
            }.font(.title2)
            //MARK: - OVERLAPPING OPACITY
            VStack{
                Text("Overlapping Opacity").bold()
                    .font(.title)
                Circle()
                    .foregroundColor(.yellow)
                    .frame(height: 100)
                    .opacity(0.5)
                Circle()
                    .foregroundColor(.red)
                    .frame(height: 100)
                    .opacity(0.5)
                    .padding(-60)
            }.padding(60)
        }
    }
}

上述代码将产生以下结果,如图 图 2**.5 所示:

图 2.5:动画化不透明度

图 2.5:动画化不透明度

在我们的第一个例子中,动画变量名为 appear,其默认值设置为 true,这显示了圆圈。当设置为 false 时,圆圈会自己动画化直到完全消失。再次按下按钮时,动画被设置为 true,圆圈再次变得可见。再次使用 animation 修饰符来初始化从起始值到结束值的插值,所以圆圈不会瞬间出现或消失;直到动画结束时,状态会有一个渐进的变化。

屏幕底部两个圆重叠的第二个例子展示了 SwiftUI 中不透明度的一个独特组件。当我们对一个已经改变不透明度的视图应用 opacity 修饰符时,该修饰符会乘以整体效果。例如,黄色和红色圆圈的不透明度设置为 50%,相互重叠。上面的红色圆圈允许一些下面的黄色圆圈透过来,从而乘以不透明度效果,使得该区域稍微暗一些,同时混合两种颜色,形成橙色。

接下来,让我们看看如何使用 scaleEffect 修饰符来动画化视图的尺寸或缩放。

缩放

每个视图都有一个特定的尺寸,我们可以通过缩放动画来改变这个尺寸。我们可以使用 scaleEffect 修饰符来做这件事。以下是一个我们如何动画化视图缩放的例子:

struct Scale_Example_One: View {
    @State private var scaleCircle = false
    var body: some View {
            //MARK: - ANIMATE THE SCALE OF A CIRCLE SHAPE
            VStack {
                Text("SCALE SHAPE").font(.title).bold()
                Circle()
                    .frame(width: 150)
                    .foregroundColor(.green)
                    .scaleEffect(scaleCircle ? 0.1 : 1)
                    .animation(.default, value: scaleCircle)
                Button("Scale Shape") {
                    scaleCircle.toggle()
                }
            }.font(.title2)
        }
    }

上述代码将产生以下结果,如图 图 2**.6 所示:

图 2.6:缩放形状

图 2.6:缩放形状

您应该开始认识到我们使用的许多代码;例如,我们使用VStack来持有我们的视图,这样它们就可以垂直堆叠,并且我们可以使用按钮控件作为启动动画的方式。

在这个例子中,我创建了一个简单的绿色圆圈,并使用scaleEffect修改器,传入我们的动画变量。当状态变为true时,圆缩小到其大小的十分之一,而当false时,它恢复到原始大小。

我们再次使用带有默认时间曲线的animation修改器。默认曲线是一个 easeInOut 曲线,我们之前在本章中讨论过。easeInOut 曲线将使动画开始缓慢,然后加速到最高速度,最后再缓慢结束。

让我们看看另一个缩放上下文的例子,但这次我们不是缩放使用圆初始化器创建的形状,而是使用系统图片来展示您也可以缩放图片:

struct Scale_Example_Two: View {
    @State private var scaleBug = false
    var body: some View {
        //MARK: - ANIMATE THE SCALE OF A SYSTEM IMAGE
        VStack{
            Text("SCALE IMAGE").font(.title).bold()
            Image(systemName: "ladybug.fill")
                .renderingMode(.original) //allows multicolor                   for SF Symbols
                .resizable()
                .frame(width: 150, height: 150, alignment:                   .center)
                .scaleEffect(scaleBug ? 0.1 : 1)
                .animation(.default, value: scaleBug)
                .padding(10)
            Button("Scale Image") {
                scaleBug.toggle()
            }
        }.font(.title2)
    }
}

上一段代码将产生以下结果,如图图 2.7所示:

图 2.7:缩放图像

图 2.7:缩放图像

这张特定的图片是来自SF Symbols应用的一个系统图片。如果您还没有这个应用,我强烈推荐它。您可以在 Apple 开发者门户免费下载它。在应用中,苹果公司给了我们数千张可以在我们的代码中使用的图片。最新版本的新功能是现在,许多图片可以以多色渲染:我们必须将渲染模式设置为.original,这样图片就会以彩色显示,而不是只有黑白。

注意

并非所有图片都可以着色。查看 SF Symbols 应用以查看哪些可以着色。

最后,在这个缩放动画的第三个例子中,我们使用anchor方法,通过相对于锚点在水平和垂直方向上按给定量缩放视图:

struct Scale_Example_Three: View {
    @State private var scaleFromAnchor = true
    var body: some View {
                VStack{
            Text("SCALE FROM ANCHOR ").font(.title).bold()
            Image(systemName: "heart.fill")
                .renderingMode(.original) //allows the use of                   multicolor for SF Symbols
                .resizable()
                .frame(width: 150, height: 125, alignment:                   .center)
                .scaleEffect(scaleFromAnchor ? 1 : 0.2, anchor:                   .bottomTrailing)
                .animation(.default, value: scaleFromAnchor)
                .padding(10)
            Button("Scale from Anchor") {
                scaleFromAnchor.toggle()
            }
        }.font(.title2)
    }
}

上一段代码将产生以下结果,如图图 2.8所示:

图 2.8:从锚点缩放

图 2.8:从锚点缩放

所有视图都有一个锚点,通常位于视图的中间。但我们可以改变这个锚点,让动画根据锚点位置缩放对象。在代码中,我使用了.bottomTrailing选项作为锚点,所以当我们按下按钮时,心形图片会缩小并向着尾部边缘(屏幕的右侧)缩放,而不是从对象的中心缩放。然而,SwiftUI 也给了我们以下可选择的锚点:

  • bottomTrailing

  • trailing

  • bottom

  • center

  • top

  • bottomLeading

  • topLeading

  • topTrailing

  • leading

在本节的最后,我们将探讨三个可以动画化的属性:stroketrimcornerRadius

描边、修剪和圆角半径

现在我们来看三个可以动画化的属性:线条的描边、圆的修剪和矩形的圆角半径。

形状的笔触是指形状的轮廓或边界。它具有特定的颜色和宽度,并且可以具有各种属性,例如线帽样式或线连接样式。让我们动画化矩形的笔触,使其在每次按钮按下时变粗或变细:

struct Stroke_Example: View {
    @State private var animateStroke = false
    var body: some View {
        //MARK: - ANIMATE THE STROKE OF THE ROUNDED RECT
        VStack{
            Text("ANIMATE STROKE").font(.title).bold()
            RoundedRectangle(cornerRadius: 30)
                .stroke(Color.purple, style:                   StrokeStyle(inewidth: animateStroke ? 25 :                   1))
                .frame(width: 100, height: 100)
                .animation(.default, value: animateStroke)
            Button("Animate Stroke") {
                animateStroke.toggle()
            }
        }.font(.title2)
    }
}

在矩形周围创建一条粗或细的笔触线,如图 图 2.9 所示:

图 2.9:动画化笔触

图 2.9:动画化笔触

我们首先定义我们的动画变量,将其初始值设置为false。在stroke修饰符内部,我将animateStroke变量作为参数传递给line width参数,因此当它变为true时,它将stroke更改为25点(否则,它将是1点)。同样,我们在animation修饰符内部也使用了默认的时间曲线,当我们运行这个时,stroke会平滑地从25点的厚度修改到按下按钮再次时回到1点。

这里是另一个例子,这次我们使用了trim修饰符:

struct Trim_Example: View {
    @State private var animateTrim = false
    @State private var circleTrim: CGFloat = 1.0

    var body: some View {
        //MARK: - ANIMATE THE TRIM MODIFIER OF A CIRCLE
        VStack {
            Text("ANIMATE TRIM").font(.title).bold()
                .padding(.top, 10)
            Circle()
                .trim(from: 0, to: circleTrim)
                .stroke(Color.red, style: StrokeStyle(inewidth:                   30, lineCap: CGLineCap.round))
                .frame(height: 150)
                .rotationEffect(.degrees(180))
                .animation(.default, value: animateTrim)
                .padding(.bottom, 20)
            Button("Animate Trim") {
                animateTrim.toggle()
                circleTrim = animateTrim ? 0.25 : 1
            }
        }.font(.title2)
    }
}

trim修饰符接受两个参数:from(表示我们想要从圆的哪一部分开始剪裁)和to(表示我们想要在哪里结束剪裁)。from参数设置为0,这意味着屏幕上将有一个完整的圆,因为我们还没有开始剪裁。代码产生以下结果,一个在按钮按下时剪掉并恢复线条的圆,如图 图 2.10 所示:

图 2.10:动画化剪裁

图 2.10:动画化剪裁

此外,请注意我们使用了两个@State变量来与trim修饰符一起工作,一个称为animateTrim,用于触发动画,另一个称为circleTrim,它是一个CGFloat类型的数值。这个变量将保存我们想要剪掉的圆的量。最初,它被设置为1,因此整个圆是可见的。

注意

CGFloat类型是一个浮点数。CG代表Core Graphics,它是一个较老的编码范式,曾用于苹果的图形框架,但现在仍在 SwiftUI 中使用。

在按钮代码内部查看,然后在circleTrim变量中,我们使用三元运算符存储两个值之一:.251。这意味着当animateTrim切换到true时,代码会剪掉 75%的圆并留下 25%;当animateTrim切换到false时,使用的是1的值,它代表 100%的圆。因此,三元运算符中的值表示要保留圆的多少部分。

如果我们运行代码,我们会看到我们有一个漂亮的圆剪裁动画。名为CGLineCap.round的代码行指的是绘制在端点的线的形状,它可以是指roundsquarebutt线帽。

为了在这里有点乐趣,如果我们进入 trim 修饰符并将 from 参数更改为 0.5 而不是 0,我们现在开始从圆的一半开始绘制。运行代码,看起来我们正在动画或绘制一个微笑,然后当我们再次按下按钮时移除微笑。

注意

如果这段代码看起来有点复杂,你看到为圆形设置了 trim 修饰符,并在按钮体中设置了 circleTrim 变量,那么将 trim 修饰符视为裁剪的“哪里”部分。这意味着我们想要从哪里开始和结束裁剪?然后,将按钮内部的三元运算符视为“多少”,意味着我们想要裁剪掉圆的多少,以及我们想要保留多少?

现在我们继续到最后一个示例。在这个示例中,我们将看看如何动画化矩形的角落半径。角落半径指的是你想要使矩形角落多尖锐;你可以从 90° 角一直调整到一个更高的值,以创建一个平滑、圆滑的角落。

所有代码与我们迄今为止使用的代码类似,只是使用了 cornerRadius 修饰符。以下是一个代码示例:

struct Corner_Radius_Example: View {
    @State private var animateCornerRadius = false

    var body: some View {
        //MARK: - ANIMATE THE CORNER RADIUS
        VStack{
            Text("ANIMATE CORNER RADIUS").font(.title).bold()
                .padding(.top, 30)
            Rectangle()
                .foregroundColor(.green)
                .frame(width: 150, height: 150)
            .cornerRadius(animateCornerRadius ? 0 : 75)
            .animation(.default, value: animateCornerRadius)
            .padding(.bottom, 20)
            Button("Animate Corner Radius") {
                animateCornerRadius.toggle()
            }
        }.font(.title2)
    }
}

这段代码产生了以下结果:一个角落半径从 90° 角一直调整到创建一个圆的矩形。所以,当我们按下按钮时,我们正在将矩形变成圆形,然后再变回矩形,如图 图 2**.11 所示:

图 2.11:动画角落半径

图 2.11:动画角落半径

在代码中,做大部分工作的这一行是:

.cornerRadius(animateCornerRadius ? 0 : 75) 

animateCornerRadius 变量被传递到 cornerRadius 修饰符中,然后检查其 truefalse 值;如果它是 false,则将其值设置为 75,这将使这个矩形的尺寸动画变为一个完美的圆形。当切换回 true 时,通过将其角落半径更改为 0,圆形动画变为一个具有 90 度角落的矩形。

注意,代码创建一个完美的圆的原因是我们将矩形的宽度和高度框架设置为 150 点,从而创建了一个正方形,并且每次当你将角落半径设置为正方形宽度和高度的一半时,你总是会得到一个完美的圆。

除了这些之外,SwiftUI 还提供了更多动画对象的方法,我们将在开始构建项目时在接下来的章节中探讨它们。

概述

在本章中,我们探讨了动画的工作原理,SwiftUI 中的两种动画类型:隐式和显式,以及许多可以动画化的属性。这些包括色调旋转、不透明度、视图在屏幕上的位置和大小、描边、裁剪、角落半径和计时曲线。

这是一步重要的步骤,旨在引导你在 SwiftUI 动画冒险中前进。记住,如果一个属性是数值,它几乎总是可以应用动画。

在下一章中,我们将开始着手一些项目。对于我们的第一个项目,我们将创建一个类似于苹果公司呼吸应用的 APP(在苹果手表上非常受欢迎)并学习如何在视图中结合多个动画。

第三章:创建一个呼吸应用

到目前为止,我们已经了解了 SwiftUI 的基础知识,一些可以用来以某种方式改变视图的修饰符,以及许多可以动画化的属性。现在是时候将所学知识付诸实践,并构建我们的第一个项目。

在本章中,我们将构建一个类似于在 Apple Watch 和 iPhone 上流行的呼吸应用的动画。我们将结合三个动画,使六个圆圈视图移动,重现 Apple 应用缓慢而有节奏的运动。

制作这个应用不需要很多代码;然而,随着我们阅读本书,我们将在此基础上构建项目,并逐渐增加难度。

完成项目所需的步骤如下:

  • 使用 Xcode 设置项目

  • 添加变量

  • 实现背景颜色

  • 添加圆圈

  • 动画圆圈

技术要求

你可以从 GitHub 的 Chapter 3 文件夹下载完成的项目的代码:github.com/PacktPublishing/Animating-SwiftUI-Applications

使用 Xcode 设置项目

让我们先打开 Xcode。然后,选择iOS选项以仅将项目构建在 iPhone 上,或者选择多平台如果你想让这个项目在 iPhone、iPad 或 Mac 上运行。之后,从模板列表中选择名为App的模板,然后点击下一步

现在,给项目起一个名字。我的是 Animating Circles,但你可以取任何你喜欢的名字。然后,填写页面上的其余详细信息。两个复选框可以保持未选中状态,因为我们不使用Core Data测试。最后,选择一个保存项目的地方。我通常直接将它们保存在我的桌面上。

然后,我们将进入 Xcode 界面;你会注意到 Xcode 自动导入了 SwiftUI 框架,因此我们可以直接进入我们的项目。

添加变量

我们的首要任务是添加一些变量来跟踪动画。将会有三个动画,所以在 ContentView 中,我们需要为它们准备三个布尔变量。我们需要给它们一个初始值 false,当应用首次启动时,这个值将被改为 true

struct ContentView: View {
    //animation bools
    @State var scaleUpDown = false
    @State var rotateInOut = false
    @State var moveInOut = false

变量名为 scaleUpDownrotateInOutMoveInOut。通常,当你命名变量时,你希望尽可能使它们具有描述性,这样你就不必猜测它们的作用,并且可以立即识别它们,就像我们在这里做的那样。

所有变量现在都已就绪,让我们继续查看动画的背景。

实现背景颜色

对于背景,我们将进入 body 计算属性。这是我们在屏幕上添加用户将看到的视图的地方。我们首先想要添加的是 ZStack;这是将要持有所有视图的主要堆叠:

var body: some View {
      ZStack {
             }
      }

我们使用ZStack而不是HStackVStack的原因是我们希望视图堆叠在一起,这样它们就只显示为一个视图,稍后我们将使用不同的修饰符分别对它们进行动画处理。

ZStack内部,让我们通过使用.foregroundColor修饰符为屏幕设置黑色背景,并指定要使用的颜色;在这种情况下,我们将使用黑色:

Rectangle() 
.foregroundColor(Color.black)

以下图显示了该代码的结果:

图 3.1:添加黑色背景

图 3.1:添加黑色背景

如您所见,在 SwiftUI 中添加背景时,它将覆盖 iPhone 屏幕的大部分区域,但 iPhone 上有一个称为“安全区域”的区域。这是为 iPhone 顶部(听筒区域)和手机底部预留的区域,那里彩色背景无法达到。

SwiftUI 允许我们使用.edgesIgnoringSafeArea修饰符隐藏安全区域,然后传入值.all,这将黑色颜色扩展到 iPhone 屏幕的所有边缘。为此,只需在之前添加的.fourgroundColor(Color.black)行下面添加这一行代码:

.edgesIgnoringSafeArea(.all)

这会产生一个完全覆盖的 iPhone 屏幕,背景为黑色,如下所示:

图 3.2:使用 .edgesIgnoringSafeArea 修饰符

图 3.2:使用 .edgesIgnoringSafeArea 修饰符

此外,还有.vertical.horizontal.leading.trailing值,您可以使用这些值使.edgesIgnoringSafeArea修饰符更具体,取决于您想忽略设备的部分。

因此,我们已经设置了动画的背景。现在,是时候添加圆圈了。

添加圆圈

让我们简要回顾一下这个项目的目标。我们希望使六个圆圈生长和收缩,同时旋转它们,并使它们进出。六个圆圈将相互重叠,这会给它们增添一种美观,因为它们将是部分透明的。

要使这生效,我们需要更多的ZStack,然后将圆圈成对地放入其中。圆圈相对于彼此的对齐方式可以比作时钟上的数字。按照这个时钟类比,我们需要一个ZStack来容纳三对:

  • 第一对圆圈将放置在 12 点和 6 点位置

  • 第二对圆圈将放置在 2 点和 7 点位置

  • 第三对圆圈将放置在 10 点和 4 点位置

让我们看看如何添加这三对圆圈。

添加第一对圆圈

我们将添加的第一对圆圈将放置在 12 点和 6 点位置。以下是我们需要完成此操作的代码:

//MARK: - ZStack for the 12 and 6 O'clock circles
            ZStack {
                ZStack {
                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .top, endPoint: .bottom))
                        .frame(width: 120, height: 120)
                        .offset(y: moveInOut ? -60 : 0)

                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .bottom, endPoint: .top))
                        .frame(width: 120, height: 120,                           alignment: .center)
                        .offset(y: moveInOut ? 60 : 0)
                }.opacity(0.5)

这段代码使用圆形状初始化器创建了两个圆圈。每个圆圈都获得了一种渐变色,使得它们在屏幕中心部分更亮,而在相反方向上更暗。渐变通过在两种颜色之间填充平滑过渡来工作。在这种情况下,渐变从绿色过渡到白色。LinearGradient 结构体用于创建渐变,它接受一个 gradient 参数,这是一个 Gradient 结构体的实例。

Gradient 结构体接受一个名为 colors 的参数,它是一个颜色值的数组。在这种情况下,colors 参数被设置为 [.green, .white],这意味着渐变将从绿色过渡到白色。

LinearGradient 结构体的 startPointendPoint 参数决定了渐变的方向。startPoint 参数被设置为 .top,而 endPoint 参数被设置为 .bottom,对于第一个圆圈来说,这意味着渐变将从圆圈的顶部开始,向下延伸。对于第二个圆圈,startPoint 参数被设置为 .bottom,而 endPoint 参数被设置为 .top,这意味着渐变将从圆圈的底部开始,向上延伸。我们追求的效果是,在圆圈相互接触的部分呈现出更浅的绿色,而在圆圈的相对部分呈现出更深的绿色。

所有的圆圈最初都将具有相同的大小,宽度为 120 点,高度也为 120 点,这是通过使用 frame 修改器来实现的。因为我们处于 ZStack 中,所以两个圆圈将堆叠在一起。如果你现在想同时看到这两个圆圈,那么请将 moveInOut 状态属性更改为 true。当我们在后面添加 onAppear 修改器代码时,moveInOut 属性将被设置为 true,但为了现在看到 UI 的形状,请将此属性更改为 true,你应该看到的是:

图 3.3:十二点和六点钟的圆圈

图 3.3:十二点和六点钟的圆圈

现在我们来看看 moveInOut 变量。记得我提到过我们的变量命名应该是描述性的,并且应该与它们的功能相关吗?嗯,moveInOut 变量就是这种描述性命名的例子,因为它将使圆圈相互进入和退出。它通过控制包含在 ZStack 中的圆圈的垂直偏移来实现这一点。三元运算符负责通过在两个不同的数字之间进行选择来设置 moveInOut 的值。

moveInOuttrue时,第一个圆圈有一个y偏移量为-60,这将其向上移动 60 点。第二个圆圈有一个y偏移量为60,这将其向下移动 60 点。这导致两个圆圈分别向ZStack的顶部和底部移动。当moveInOutfalse时,第一个圆圈的y偏移量为0,这使其保持在ZStack的中心,第二个圆圈也有一个y偏移量为0,这也使其保持在ZStack的中心。这导致两个圆圈保持在中心,相互重叠。

接下来,通过在ZStack末尾添加不透明度修饰符,两个圆圈都被设置为 50%的不透明度。这使得我们可以轻松地透过它们看到其他我们将要添加的圆圈,因为它们在动画过程中相互重叠,这也使得颜色在重叠期间混合并变暗,视觉效果很好。

添加第二对圆圈

现在,对于下一组圆圈,我们几乎要做完全相同的事情... 几乎。继续使用我们的时钟类比,我们需要在两点和七点钟位置放置圆圈。首先,我将向您展示代码,然后我将解释新的部分:

//MARK: - ZStack for the 2 and 7 o'clock circles
                ZStack {
                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .top, endPoint: .bottom))
                        .frame(width: 120, height: 120,                           alignment: .center)
                        .offset(y: moveInOut ? -60 : 0)
                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .bottom, endPoint: .top))
                        .frame(width: 120, height: 120,                           alignment: .center)
                        .offset(y: moveInOut ? 60 : 0)
                }.opacity(0.5)
                    .rotationEffect(.degrees(60))

我们正在创建两个具有绿色和白色渐变的圆圈,它们的宽度和高度都是 120 点,并且它们通过动画变量moveInOut进行偏移。同样,根据moveInOuttrue还是false,将决定圆圈的位置。如果是true,圆圈将分离,如果是false,它们将移动到中间,一个圆圈覆盖在另一个圆圈上。接下来,我们将这些圆圈的不透明度设置为 50%,就像我们为第一组圆圈所做的那样,使它们略微透明,这样我们就可以看到它们重叠。

对于这组圆圈,不同之处在于我们需要在它们上使用rotationEffect修饰符。这个修饰符允许我们通过传递旋转量的值来旋转圆圈的位置。

注意,这个修饰符放置在包含两个圆圈的ZStack的末尾。这种放置方式将使整个ZStack及其子元素旋转,因此它为我们节省了一些代码,因为我们不需要单独在每个子圆圈上放置修饰符。

我使用60作为.degrees参数的值,这将使这个ZStack相对于前一对圆圈旋转 60 度。旋转的60值是 120 的一半,即每个圆圈的宽度,因此这种旋转将使圆圈相互重叠一半。

再次,如果您想看到这两对圆圈的外观,将moveInOut属性更改为true,这就是结果:

图 3.4:两点和七点钟位置的圆圈

图 3.4:两点和七点钟位置的圆圈

注意,我们不需要使用rotationEffect修饰符来旋转第一对圆圈;这是因为它们作为屏幕上的第一对圆圈,没有与其他圆圈重叠。如果我们在这里不使用rotationEffect修饰符,那么这第二对圆圈将正好放置在第一对圆圈上方,我们就看不到它们了。

添加第三对圆圈

最后,对于时钟上的最后一对圆圈,它们需要放置在十点和四点的位置。以下是代码:

  //MARK: - ZStack for the 10 and 4 o'clock circles
                ZStack {
                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .top, endPoint: .bottom))
                        .frame(width: 120, height: 120, alignment: .center)
                        .offset(y: moveInOut ? -60 : 0)
                    Circle().fill(LinearGradient(gradient:                       Gradient(colors: [.green, .white]),                       startPoint: .bottom, endPoint: .top))
                        .frame(width: 120, height: 120,                           alignment: .center)
                        .offset(y: moveInOut ? 60 : 0)
                }.opacity(0.5)
                    .rotationEffect(.degrees(120))

看着这段代码,我们可以再次看到它与其他圆圈集几乎没有区别。我们添加了两个带有绿色和白色渐变的圆圈,尺寸为 120 点。它们在y轴上向内或向外移动 60 点或-60 点,正如我们所看到的,但对于这对圆圈,我们旋转它们 120 度,将它们放置在十点和四点的位置,从而完成圆圈时钟。结果如下所示:

图 3.5:十点和四点圆圈——完整设计

图 3.5:十点和四点圆圈——完整设计

好的,所以我们已经添加了所有的圆圈。现在到了添加有趣的部分——动画——并让它们移动的时候了。

动画化圆圈

现在我们已经将所有的圆圈对都放置好了,是时候开始动画化了。我将添加动画代码。一开始它可能看起来有点奇怪,但不用担心,我会逐行解释:

            //MARK: - Animations
            .rotationEffect(.degrees(rotateInOut ? 90 : 0))
            .scaleEffect(scaleUpDown ? 1 : 1/4)
            .animation(Animation.easeInOut.              repeatForever(autoreverses: true).speed(1/8),               value: scaleInOut)
            .onAppear() {
                rotateInOut.toggle()
                scaleUpDown.toggle()
                moveInOut.toggle()
            }
        }
    }
}

第一行代码调用rotationEffect修饰符。对于其.degrees参数,我传递了rotateInOut变量,然后通过三元运算符进行检查。三元运算符有两个可选值,900。如果rotateInOut变量为true,则rotationEffect修饰符将旋转包含所有圆圈对的ZStack元素 90 度。当rotateInOutfalse时,rotationEffect修饰符将ZStack旋转回0。因此,所有圆圈将同时旋转到 90 度或回到零,具体取决于rotateInOut包含的值。

下一行代码是缩放效果动画。对于scaleEffect修饰符,我们传递另一个三元运算符,它有两个要设置的值,即11/4。当scaleUpDown属性为true时,所有圆圈都将处于全尺寸,这由值1反映出来;否则,当scaleUpDown属性为false时,所有圆圈将缩小到其尺寸的四分之一。

下一行代码调用.animation函数。这是一个神奇的功能,可以将动画应用于我们放置的任何视图。我们将它放在包含所有圆圈的ZStack的末尾,所以当任何值发生变化时,例如,变量从true变为false或相反,新的值将被应用于视图,即ZStack及其子项。这些新值不会立即应用;它们是插值过的,所以动画可以平滑流畅地进行。

我使用easeInEaseOut时间曲线类型,并添加了.repeatForever修饰符,这将使动画在应用运行期间持续进行。通过将true值传递给autoreverses参数,当动画完成一个方向的动画时,它会反转自身,因此它可以继续并在相反方向上动画化。

我们还可以设置动画的速度。我在.speed修饰符内部使用1/8作为值,以实现相对较慢的动画。由于这个项目类似于熟悉的呼吸应用,我认为较慢的动画比快速的动画更合适,因为缓慢的节奏有助于集中注意力进行呼吸。

value参数需要一个我们的@State变量,以便它可以监控其变化。我们使用的所有变量都是@State,它们将在某个时刻改变其值,所以任何一个都可以在这个参数中正常工作。

项目的最后一部分是改变我们每个变量的值,以便动画可以工作。记住我们通过使用.onAppear修饰符查看动画的不同触发器。这将执行一个动作,当屏幕或视图首次出现时,然后触发动画。我们想要在应用启动时立即执行的操作是将每个变量切换到其相反的状态。我们给它们初始值false,但在onAppear内部,它们被切换到true,从而启动动画。

注意

如果你之前测试了应用并更改了moveInOut变量为true,请确保将其再次设置为false,这样动画就会在onAppear修饰符中被触发。

现在我们有三个动画同时发生:

  • 圆圈在彼此之间移动进出。当向内移动时,圆圈将完全重叠,当向外移动时,它们将分离,直到它们的边缘刚好接触。同样,这个动画由moveInOut状态变量监控。

  • 第二个动画是通过使用scaleEffect修饰符来缩放圆圈。它接受一个参数,这是一个介于01之间的值,表示要应用的缩放量。在这种情况下,scaleUpDown变量被用来控制传递给scaleEffect修饰符的值。

  • rotateInOuttrue时,最终动画将使所有圆圈旋转 90 度,而当rotateInOutfalse时,将它们旋转回 0 度。

运行应用程序,并对其进行一些操作。以下图显示了动画将采取的序列:

图 3.6:动画序列

图 3.6:动画序列

理解不同修饰符和函数如何工作的最佳方式是通过传递不同的值并进行实验。在所有事情上——对于参数、例如渐变的颜色、圆圈的大小、速度、位置、旋转量等等——始终可以自由发挥你的创造力。将参数值调整到你喜欢的样子,这将帮助你更好地理解每个修饰符是如何作用于视图的。

在本书的后面部分,你将学习如何向项目中添加声音和音乐,以及如何添加按钮和滑动控件。当你知道如何做到这一点时,你可以回到这个项目,对其进行调整以包含一些音乐,也许还有一个滑动条来改变动画的速度。例如,由于这是一个呼吸应用,有些人可能希望通过深呼吸并保持几秒钟来放松,你可以调整动画以暂停任意长的时间来表示呼吸的保持,所有这些都可以通过使用你将在即将到来的项目中了解到的各种控件来完成。

摘要

在完成这个第一个项目方面做得很好!通过创建一个呼吸应用,你不得不探索如何使用 SwiftUI 直观的修饰符和设计工具来旋转、缩放和移动视图到另一个位置。我们还使用了一个特殊的修饰符,它在后台为我们做了很多工作,即.animation修饰符,它在定义起始点和终点后对值进行插值,并从这些值创建出平滑无缝的动画。

在下一章中,我们将继续我们的动画之旅,并构建一个唱机。这个项目将探讨如何围绕视图的一个锚点进行动画,而不是从中心开始,以及添加声音和按钮以启动动画。

第四章:构建唱片机

在这个项目中,我们将创建一个唱片机,它将移动一个臂到唱片上,当按下按钮时使唱片旋转并播放音乐。

当然,唱片机现在可能有点过时了,但这个项目是学习旋转新技术的良好方式——特别是如何围绕锚点旋转对象。而且,你可以随时修改设计,让它看起来像转盘,因为许多 DJ 仍然使用黑胶唱片,尤其是在过去几年黑胶唱片复兴的背景下。

你可能正在想,我们不是在第一个项目中旋转过圆圈吗? 好吧,这个项目是不同的。在上一个项目中,我们应用的旋转动画是在 SwiftUI 创建的形状上(特别是圆圈),但在这个项目中,我们将对照片图像应用旋转动画,然后通过按钮控制它,并加入一些声音来增强用户体验。

在我们开始之前,让我们列出这个项目的目标:

  • 将图片添加到资产目录

  • 创建唱片机元素

  • 将所有元素组合到一个视图中

  • 测试项目

技术要求

您可以在 GitHub 上的Chapter 4文件夹中找到完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

将图片添加到资产目录

好的,让我们开始第一个目标:向项目中添加一些图片。首先,我们需要在 Xcode 中创建一个新的项目。我把它命名为Record Player(你可以复制这个名称或者如果你喜欢可以选择其他名称)。然后,我们需要填写其他字段,就像我们在上一章中做的那样。一旦完成,我们就可以开始了。

当涉及到图片本身时,我们需要三张:唱片、将在唱片上移动的唱片机臂,以及我们可以用作唱片机箱的木纹图像。所有这些图片都可以在 GitHub 上通过点击技术要求部分提供的链接找到。

添加图片到项目的主要方法有三个。

第一种方法是在 Xcode 中使用一个特殊的文件文件夹,即Assets.xcassets文件,然后您将被带到资产目录。目录分为两个部分:左侧部分是文件列表的地方,右侧部分是当您在左侧窗格中点击它们时可以看到文件的地方。要将您的图片拖入目录,只需将它们拖到左侧列即可:

图 4.1:将文件添加到资产目录

图 4.1:将文件添加到资产目录

这些图片将通过在代码中引用它们的名称来访问(我们很快就会看到如何做到这一点)。

向项目中添加图片文件的第二种方法是将它们直接拖放到项目导航器中。然而,这种方法包括一个额外的步骤,即通过以下弹出窗口选择您想要如何以及在哪里将文件复制到项目中:

图 4.2:通过项目导航器添加文件

图 4.2:通过项目导航器添加文件

在这里,你想要确保勾选了说如果需要则复制项目的复选框。这是很重要的,因为 Xcode 会复制你的文件到项目中,所以如果它们在你的电脑上不再可用,那没关系,因为它们现在是项目的一部分了。

你还想要确保你勾选了你想复制那些资源的特定目标。例如,如果你想在你的 Mac 上构建应用,那么你想要在添加到目标框中勾选唱机 (macOS)选项。如果你只是为 iPhone 构建它,那么勾选唱机 (iOS)选项。你也可以两者都勾选,如果你更喜欢的话。

最后一种方法是打开文件菜单并选择添加文件到…选项:

图 4.3:通过文件菜单添加文件到项目

图 4.3:通过文件菜单添加文件到项目

现在我们已经添加了所有图像,下一个目标是创建唱机。

创建唱机元素

要创建我们的唱机,我们将创建三个独立的文件,每个文件负责执行特定的任务:

  • 第一个文件将包含唱机盒子

  • 第二个文件将包含旋转的唱片,唱机臂和用于控制它的按钮

  • 第三个文件将包含当唱机动画开始时播放的声音文件

让我们从第一个文件开始。

创建唱机盒子

要创建包含唱机盒子的文件,在 Xcode 中打开文件菜单,选择新建,然后选择文件。你会注意到这会弹出一个模板选项:

图 4.4:创建新的 SwiftUI 文件

图 4.4:创建新的 SwiftUI 文件

查看窗口顶部,选项卡行让你选择你想为哪个平台编写代码。我们只对 iOS 应用感兴趣,所以选择iOS选项卡。然后,在用户界面标题下,选择SwiftUI 视图

点击RecordPlayerBox。确保你的目标在复选框中被选中,这样当运行项目时,文件及其包含的一切都将正常工作。一旦你的目标被设置,点击创建

现在,我们有了新的文件来编写我们的代码,你会注意到它与ContentView文件相同,只是它被命名为RecordPlayerBox。在body属性中工作,我们将从一个ZStack开始;这将是我们存放所有视图的主要堆栈。在ZStack内部,我们可以使用资产库中的一个图像来构建一个矩形。输入以下代码,然后让我们看看它做了什么:

ZStack {
            Rectangle()
                .frame(width: 345, height: 345)
                .cornerRadius(10)
            Image("woodGrain")
                .resizable().frame(width: 325, height: 325)
                .shadow(color: .white, radius: 3, x: 0, y: 0)
        }

首先,我们添加了一个Rectangle视图,然后,使用frame修饰符,它获得了345点的宽度和高度,形成了一个正方形。

然后,使用cornerRadius修饰符,我们将矩形的角落圆滑了10点。

下一行代码是Image初始化器,它是一个显示图像的视图。我们想要使用 Assets 目录中的木纹图像,所以我们通过在Image初始化器中输入图像的名称来访问它,以创建一个字符串。在我们的例子中,我们输入了"woodGrain"

现在我们已经有了我们的图像,但我们需要调整其大小。为了做到这一点,我们需要使用resizable修饰符,它用于准备一个图像或其他视图以便调整大小,使其适合其父视图。当它应用于Image视图时,resizable修饰符将确定图像应该如何缩放以适应可用空间。

然后,frame修饰符将木纹图像的大小调整为325点宽度和高度。这将使其略小于矩形的尺寸;然而,减小它将允许一些矩形边缘显示出来,从而创建一个边框。边框之所以是黑色,是因为创建矩形形状的默认颜色是黑色,就像所有形状一样。我们可以通过使用color修饰符将其更改为我们想要的任何颜色,但我想我会在这里保持黑色。

最后一行代码通过使用shadow修饰符创建阴影。同样,我们添加的所有修饰符都是为了木纹图像,所以每个放置在木纹上的修饰符都有其特定的任务,以某种方式修改木纹图像。由于这个修饰符放置在木纹图像上,并且使用白色颜色,因此会在图像周围辐射出白色阴影。将半径设置为3意味着阴影将从图像延伸出3点。我们还可以选择为xy参数输入一个值,这将沿着这些轴移动阴影,向左、向右、向上或向下。

阴影移动的量取决于你放入的值的尺寸。例如,如果你为x参数输入一个值为10,阴影将从右侧边缘延伸出10点;如果你为y参数输入一个值为10,阴影将从盒子的底部边缘延伸出10点。我输入了一个值为0,因为我想要阴影直接覆盖木纹图像;它不会向左或向右或向上或向下移动。

通过调整这些数字和阴影的颜色,你可以看到我们使用的值对阴影位置和强度的影响有多大。不过,尽管如此,你仍然不太可能看到shadow修饰符带来的明显差异,因为它的颜色是白色,而我们是在白色背景上工作的。当我们稍后为整个背景添加渐变时,阴影将变得更加突出。

注意

你可以通过 Xcode 缩进来轻松地看到任何给定视图上的修饰符。例如,再次查看Rectangle视图。它的两个修饰符framecornerRadius向右缩进,这意味着它们只作用于矩形。同样,对于Image("Woodgrain");它的两个修饰符也向右缩进。

如果你的代码开始变得混乱,缩进没有对齐,这里有一个快捷方式:按下Command + A选择文件中的所有内容,然后按下Control + I。Xcode 然后一次性正确缩进整个文件,每一行代码。

现在,看看预览窗口,看看你的唱片机盒子看起来如何。以下是根据我们编写的代码我所看到的内容:

图 4.5:完成的盒子

图 4.5:完成的盒子

在我们继续之前,我想向你展示如何修改预览,以显示适合屏幕上视图或视图的大小。注意在图 4.5中,我们的盒子比 iPhone 预览屏幕小得多;有时,你可能想要在一个适合你刚刚制作的视图大小的屏幕上预览你所做的,在我们的例子中,我们制作了一个盒子视图。以下是缩小预览屏幕以适应盒子视图的方法。

在每个 SwiftUI 文件的底部都有一个结构体,其名称将与我们刚才用来编写代码的结构体相同。这是一个用于开发目的的特殊结构体,它创建了我们需要的预览窗口,这样我们就可以实时查看我们的工作。

在预览结构体中,在RecordPlayerBox()代码的末尾添加以下修饰符:

.previewLayout(.sizeThatFits).padding()

使用sizeThatFits值将预览窗口的大小降低到与我们的完成盒子大致相同的大小。我不想让它完全一样的大小,所以我给它周围添加了一些填充。这是结果:

图 4.6:完成的盒子适应预览屏幕

图 4.6:完成的盒子适应预览屏幕

通过使用previewLayout修饰符,你可以自定义预览窗口以满足你的需求。

这就是我们的完成盒子。简单,对吧?现在,唱片机盒子完成之后,我们可以继续我们的下一个目标:创建旋转唱片。

创建旋转唱片

如前所述,我们的第二个文件将包含唱片代码。然而,为了简单起见,我们实际上将在这个文件中也制作唱片机臂和按钮。我们可以像处理盒子那样将这些任务分开到单独的文件中,这样每个部分都有自己的文件,但我认为对于这个小型项目,这个文件就可以为我们完成工作。

由于我们在这个文件中创建了三个不同的视图,让我们给它起一个名字,反映文件将拥有的每个视图,所以叫RecordButtonArmView。现在,你还记得如何创建一个新的 SwiftUI 文件吗?如果不记得,请回到创建唱片机盒子部分进行复习。还有一个创建新文件的快捷方式:只需按下Command + N,然后选择你想要使用的文件模板;我们想要的是选项中的SwiftUI View模板。

文件创建完成后,让我们着手制作那个旋转唱片。就像我们之前做的那样,我们需要属性来使一切正常工作,所以我们在文件的顶部,在结构体内部,添加以下变量:

    @State private var rotateRecord = false
    @State private var moveArm = false
    @State private var duration = 0.0

    var animateForever: Animation {
        Animation.linear(duration: duration)
            .repeatForever(autoreverses: false)
    }

其中一个变量有点不同,让我解释一下我们在做什么。

前三个变量是State变量。rotateRecord变量将跟踪旋转的唱片,并在其值变为true时启动旋转动画。下一个State变量moveArm将跟踪唱机臂,第三个State变量duration用于设置旋转唱片的持续时间。

最后一个变量是一个计算属性。计算属性是一个不存储值而是每次访问时都会计算其值的属性,这意味着其体内的代码会在每次变量使用时运行。我们创建的属性是Animation类型,这意味着我们可以在animation修饰符中使用它。

通过使用Animation.linear,我们为动画初始化这个变量,使用线性时间曲线。记得我们讨论过第二章中的时间曲线——线性曲线是一个没有缓动进入或缓动退出的曲线;它是一个从开始到中间再到结束的稳定、无波动的动画。

然后,通过将duration参数保持为0,这意味着动画将没有持续时间,还不能开始。这将改变,以便稍后开始动画。

然后,我们使用repeatForever选项,因为我们希望旋转的唱片继续旋转,直到我们停止它。最后,autoReverses参数设置为false,因为我们不希望唱片反向旋转。

以这种方式使用计算属性可以简化事情,因为现在我们只需要将那个属性传递给animation修饰符,一个计算属性就会设置四个值:Animation.linear曲线、durationrepeatForeverautoReverses。如果你在多个不同位置调用动画修饰符,计算属性也非常有用,因为你不需要改变所有单独的位置来改变动画,只需简单地更改一个位置中的变量。例如,如果你想在你所有的其他动画中都有自动反转功能,你只需要将autoreverses在一个位置更改为true

我们现在已经拥有了设置这个文件所需的所有变量。接下来,我们将实际添加记录。首先,让我们将所有内容放入一个主ZStack中,它将包含所有视图:

ZStack {
Image("record").resizable()
               .frame(width: 280, height: 280)
               .rotationEffect(Angle(degrees: rotateRecord ?                  360.0 : 0.0))
               .animation(animateForever.delay(1.5), value:                  rotateRecord)
 }

第一行代码通过Image初始化器访问资产目录中的"record"图像,在屏幕上创建唱片(就像我们处理"woodGrain"图像一样)。

接下来,再次使用resizable修饰符来启动调整大小,我们可以通过使用frame修饰符来改变记录图像的大小。传入宽度为280点,高度为280点的值,使图像正好适合屏幕。

在下一行代码中,我们向记录中添加了rotationEffect修饰符。这个修饰符会将图像旋转(或旋转)到我们想要的任何角度。对于degrees参数,我们使用三元运算符并将记录旋转360度,这是一个完整的旋转。因此,记录将完成一次完整的旋转,但只有在rotateRecord变量变为true时才会这样做。

接下来,我们通过使用animation修饰符并传入animateForever计算属性作为其第一个参数,将动画添加到记录中。记住,这个属性已经为我们做了几件事情:它设置了一个线性时间曲线,使动画无限重复,并阻止动画反向播放。

注意到delay修饰符的使用。这将给旋转唱片的开头添加 1.5 秒的延迟。这个延迟被添加是因为旧式唱机在臂越过唱片后才开始旋转。这将给动画增加一点现实感。

最后,动画的value参数通过rotateRecord变量传递,为记录提供动画。

在我们开始处理唱机臂之前,让我们将预览缩小,使其反映唱机的尺寸,就像我们处理盒子时做的那样:

struct RecordButtonArmView_Previews: PreviewProvider {
    static var previews: some View {
        RecordButtonArmView()
            .previewLayout(.sizeThatFits).padding()
    }
}

现在,唱片已经完成并准备好播放音乐。看起来是这样的:

图 4.7:唱片

图 4.7:唱片

目前还没有发生任何事情,因为我们必须添加其他视图和功能,但我们正在取得良好的进展。接下来,让我们开始处理唱机臂。

创建唱机臂

完成旋转的记录后,下一个目标是唱机的臂。仍然在RecordButtonArmView文件中工作,下面是我想要你添加的代码,就在调用记录的animation修饰符之后:

//Arm
            Image("playerArm").resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 150, height: 150)
                .shadow(color: .gray, radius: 2, x: 0, y: 0)
                .rotationEffect(Angle.degrees(-35), anchor:                   .topTrailing)
                .rotationEffect(Angle.degrees(moveArm ? 8 : 0),                   anchor: .topTrailing)
                .animation(Animation.linear(duration: 2),value:                   moveArm)
                .offset(x: 75, y: -30)

与记录图像类似,我们使用Image初始化器来显示我们之前导入到资产目录中的"playerArm"图像。它使用resizable修饰符获得可调整大小的行为,并将宽高比设置为fit,这将按比例将图像适配到屏幕上。接下来,使用frame修饰符将图像的宽度和高度设置为150点,正如我们之前所看到的。

继续操作,下一行代码设置了一个灰色阴影,并以2点半径投射到臂上。如果你喜欢,你可以将半径更改为你喜欢的任何大小,并尝试看看什么对你来说效果最好。

然后,臂被旋转了-35 度;这个值使其与左侧的唱片垂直对齐。我们还在这里设置了锚点为topTrailing,所以当臂越过唱片时,topTrailing(或右上角)锚点不会移动,但将作为整个臂移动的支点。

下一行代码再次调用rotationEffect修改器——这次传递的是moveArm变量。一旦这个变量变为true,臂部将向左移动8点,而当它是false时,它将回到原来的位置,使用值为0

正如我们所见,要有一个动画,我们需要animation修改器,这是下一行代码。它有一个线性时间曲线,并将动画设置为 2 秒完成,这意味着臂部应该用 2 秒时间移动到唱片上。

最后,我们需要将臂部放置在相对于旋转唱片的位置恰到好处,因此在这里使用offset修改器,我们可以将其放置在唱机盒上我们想要的确切位置。就我们的目的而言,唱机的臂通常放置在唱片右侧,臂和唱片之间有一英寸或两英寸的间隔。

到目前为止,项目应该看起来是这样的,臂部已经放置好:

图 4.8:唱机臂

图 4.8:唱机臂

实际上,我们几乎完成了这个项目,但还需要两个关键组件。

这些功能中的第一个是一个可以开始和停止动画的按钮。这将是一个动态按钮,意味着按钮的标题会根据其功能而改变。如果唱机没有播放唱片,我们将保持按钮颜色为黑色,显示文字播放,并显示三角形的播放符号。如果唱机正在播放唱片,那么按钮将变为红色,显示文字停止,并显示正方形的停止符号。

第二个组件是为项目添加声音。这涉及到导入一个专门为音频/视频文件制作的音频/视频框架。

让我们先处理按钮组件。

添加自定义动态按钮

按钮是一个在触发时执行动作的控制。它可以配置为显示文本标签、图像或两者兼而有之。当用户点击它时,会向其目标发送一个动作,这可以触发要执行的方法。所以,我们现在就添加一个。

仍然在RecordButtonArmView文件中工作,就像之前一样,我将添加按钮代码,然后解释它是如何工作的:

                 //Button
                Button(action: {
                    rotateRecord.toggle()
                    if rotateRecord {
                        duration = 0.8
                        moveArm = true
                    } else {
                        duration = 0.0
                        moveArm = false
\                    }
                }) {
                    HStack() {
                        if !rotateRecord {
                            Text("Play").bold().                              foregroundColor(Color.black)
                            Image(systemName: "play.circle.                              fill").foregroundColor(Color.                              black)
                        } else {
                            Text("Stop").bold().                              foregroundColor(Color.black)
                            Image(systemName: "stop.fill").                              foregroundColor(Color.red)
                        }
                    }
                    .padding(.horizontal, 10)
                    .padding(.vertical, 5)
                    .background(Capsule().strokeBorder(Color.                      black, lineWidth: 2.00))
                }.offset(x: -105, y: 135)

Button控制有一个action参数,这就是我们放置当按钮被按下时要执行代码的地方。在这个action闭包内部,我们在做其他任何事情之前切换了rotateRecord变量。这样做的原因是我们希望按钮改变变量的状态,因为这是控制旋转唱片的变量。所以,通过切换它,我们立即改变了状态。

接下来,我们检查使用if else语句时rotateRecord的状态。如果它是true,我们将持续时间变量设置为0.8秒,因为这是我们希望唱片旋转一周所需的时间(这是产生旋转唱片效果的好速度)。接下来,我们希望在按钮按下时将moveArm变量设置为true,因为将其设置为true会使臂移动到唱片上8度。

所有这些都在rotateRecordtrue时发生,但如果它是false,代码将进入else块。在else块中,duration被设置为0,这实际上停止了唱片的旋转,moveArm被设置为false,这将允许臂动画回到其原始起始位置:离开唱片,并移向右侧。

然后,我们进入按钮的标签部分。在这个初始化器中,我声明了一个HStack,并在其中使用了一个if else语句。我在rotateRecord变量前面也使用了非运算符(!),这将读作:如果rotateRecord变量不是true(这是另一种说变量是false的方式),则唱片没有播放,因此将按钮的文本设置为单词"Play",使其加粗并变黑,并提供一个系统图像(一个三角形的播放按钮)。但如果代码进入这里的else块,则意味着唱片正在旋转;在这种情况下,我们希望使文本加粗,将前景色改为红色,并提供一个停止按钮的系统图像。

为了完成按钮,我们在其水平和垂直两侧添加了一些填充,使其呈胶囊形状,并使用strokeBorder修饰符在按钮周围绘制了一条 2 点的黑色线条。

注意

你可能想知道,为什么使用非运算符(!)而不是可以说if false?你可以使用if false语句代替if !true,但!运算符可以帮助使你的代码更易读。例如,考虑以下代码:

if !fingerprintAccepted {

//access granted

} else {

//access denied

}

!运算符否定布尔值,使代码更易读,因为它强调了预期的相反。在示例中,如果fingerprintAcceptedfalse,则允许访问。如果fingerprintAcceptedtrue,则拒绝访问。!运算符清楚地表明代码正在检查fingerprintAccepted的相反值。

将该代码放入您的项目中后,当前界面应如下所示:

图 4.9:添加按钮控件

图 4.9:添加按钮控件

现在,有一个播放按钮来控制录音。接下来,我们希望我们的唱片机动画能够实际播放声音,所以让我们来做这件事。

创建一个音频文件以播放音频

现在,我们将向项目中添加声音,以便在录音旋转时播放。为此,导航回此项目的 GitHub 文件夹,并将名为music.m4a文件拖放到项目导航器中。如果该框未被勾选,请确保勾选Copy files to project框。

对于我们的.m4a音频文件,我们需要为这个声音创建一个单独的 Swift 文件。按Command + N键创建一个新文件,但不要创建 SwiftUI View 文件,而是创建一个简单的 Swift 文件。然后,将其命名为PlaySound

注意

SwiftUI View 文件和 Swift 文件之间的主要区别是文件中包含的代码的目的。SwiftUI View 文件包含定义视图及其布局所需的代码,而 Swift 文件可以包含广泛与定义视图无关的代码。

在这个文件中,我们首先需要做的是导入AVFoundation框架:

import AVFoundation

AVFoundation框架包括类和方法,允许开发者在他们的应用程序中操作和与音频和视频一起工作。

接下来,让我们实例化(创建)一个音频播放器:

var audioPlayer: AVAudioPlayer?

注意,这个变量是一个可选类型,其末尾有一个问号。我将其设置为可选,因为如果出于任何原因在项目中找不到音乐文件,它将阻止应用程序崩溃。相反,应用程序仍然可以工作,但只是不会播放音乐。

现在,让我们创建一个名为playSound的函数,该函数将在项目中搜索音频文件,并在找到时加载它:

func playSound(sound: String, type: String) {
    if let path = Bundle.main.path(forResource: sound, ofType: type) {
        do {
            audioPlayer = try AVAudioPlayer(contentsOf:               URL(fileURLWithPath: path))
            audioPlayer?.play()
        } catch {
            print("Could not find and play the sound file")
        }
    }
}

下面是这个函数的工作原理。它有两个参数,都是字符串:一个叫做sound,另一个叫做type

我们首先要做的是使用所谓的if let语句创建一个path常量。可选绑定是一个特性,它允许我们检查可选值,如果其中包含值(意味着它不是 nil),则将该可选绑定到变量或常量。

这个名为path的常量将被分配一个来自应用程序包的路径。包是应用程序及其资源存储的地方,我们需要获取添加到项目中的声音文件的路径,该文件位于应用程序包中。我们可以通过使用Bundle.main属性来访问应用程序的主包。这个属性返回一个表示应用程序主包的Bundle对象,我们可以使用这个对象来访问应用程序中的任何资源,例如图像、声音或其他文件。

因此,if let语句的读取方式如下:代码会在主包中搜索具有给定声音名称和类型扩展名的文件。如果找到,则将文件路径存储在path常量中,并在do块中运行代码。否则,如果由于任何原因找不到文件的路径,那么我们正在寻找的文件缺失或损坏,程序流程将进入catch块并执行那里的代码。

好的,让我们假设文件路径已经找到了我们的声音文件,然后代码进入do块,在这个块中,代码将尝试使用那个path常量创建一个音频播放器。如果这成功了,它将尝试播放那个文件。但是,如果由于任何原因文件无法播放,代码将进入catch块,并将错误消息打印到控制台("Could not find and play the sound file")。这个错误消息不会显示给用户,它只是为了我们的调试目的,但用户的应用程序不会因为这段代码而崩溃;只是声音不会播放。

注意

在这里,如果我们进入catch块,显示一个警告给用户将是有帮助的;然而,我们目前不会这样做。如果您想这样做,我们将在第十二章中介绍,在那里我们创建一个文字游戏。

好的,我们已经将所有东西都准备好了来测试应用程序,但在我们能够这样做之前,我们需要将我们的三个文件——RecordPlayerBoxRecordButtonArmViewPlaySound文件——合并成一个统一的视图。

将所有元素合并到一个视图中

要将我们所有的完成视图组合成一个统一的分组以制作完成的项目,让我们回到ContentView文件并添加以下代码:

struct ContentView : View {
    var body: some View {
        ZStack {
            //MARK: - ADD THE GRADIENT BACKGROUND
            RadialGradient(gradient: Gradient(colors: [.white,              .black]), center: .center, startRadius: 20,              endRadius: 600)
                .scaleEffect(1.2)
                //.ignoresSafeArea()
            //MARK: - ADD THE RECORD PLAYER BOX
            RecordPlayerBox()

            //MARK: - ADD THE RECORD, THE BUTTON, AND THE ARM
                RecordButtonArmView()
        }
    }
}

再次强调,我们使用ZStack作为主视图,因为我们想将其他视图叠加在一起。

首先,让我们看看RadialGradient视图。这是一个结构体,它接受一个颜色数组,这些颜色一个接一个地放在开闭括号之间。在这些括号内,你可以放尽可能多的颜色,每个颜色之间用逗号分隔(我在这里使用两种颜色:白色和黑色)。

RadialGradient视图通过使用数组中的第一个颜色来着色背景的中心,后续的颜色将围绕那个中心。使用startRadiusendRadius的值分别为20600,可以使径向渐变扩展以覆盖整个屏幕;然而,它不包括 iPhone 的安全区域(再次强调,那些是顶部缺口附近的小区域和手机底部的小区域)。我们可以用两种方式处理安全区域:我们可以使用之前使用过的ignoreSafeArea修饰符,或者我们可以使用这里使用的scaleEffect修饰符。通过将1.2作为scaleEffect修饰符的值传递,渐变将扩展到 iPhone 屏幕的 1.2 倍大小,覆盖所有边缘。这实际上与ignoreSafeArea修饰符所做的是一样的。

下一行代码调用RecordPlayerBox视图并将其放置在渐变之上(记住我们正在ZStack中工作,所以视图会堆叠在一起)。最后一行代码调用RecordButtonArmView,将其放置在盒子之上,以完成界面。

这就是我们的项目将看起来像什么(如果你在过程中没有做任何修改的话):

图 4.10:完成后的界面

图 4.10:完成后的界面

最后一步是使用我们创建的声音文件。这很简单,所以作为一个挑战,试着想想你会在哪里放置音频代码,让应用播放音乐。

你想出来了吗?如果你想到了在按钮的主体中放置代码,那么你是正确的!按钮是控制动作的视图:它使臂摆动到唱片上方,唱片也会旋转。所以,在 RecordButtonArmView 文件中,然后在按钮的 if 语句中,添加以下代码:

playSound(sound: "music", type: "m4a")

这行代码调用了我们在 PlaySound 文件中创建的函数,传递了我们的音乐文件名,简单地称为 music,以及 type 参数的文件扩展名,它是 m4a。当按钮被按下时,意味着用户想要旋转唱片并播放音乐,这段代码将获取音乐文件并播放它。

当按钮再次被按下时,这意味着用户想要停止音乐,因此我们需要在 else 块中添加以下代码来做到这一点:

audioPlayer?.stop()

这行代码调用了我们创建的音频播放器,并使用 stop 函数停止音乐。注意在调用 stop 函数之前使用问号。这是因为 audioPlayer 变量被创建为可选的。当我们创建可选变量时,在使用它们时也需要使用问号或感叹号。

测试项目

就这样,项目完成了。让我们回到 ContentView 并测试一下。如果你在 预览 窗口中点击 播放 按钮,或者在模拟器中运行它,你应该会看到唱片只有在播放器的臂直接在唱片上方时才会开始旋转。当唱片开始旋转时,你应该会听到来自 40 年代大乐队时代的经典老歌,包括老唱片臭名昭著的嘶嘶声。你还会注意到按钮上的文字从 播放 变为 停止,按钮的颜色从黑色变为红色,如图所示:

图 4.11:旋转唱片

图 4.11:旋转唱片

当你按下 停止 时,臂会回到原来的位置,唱片停止旋转,播放 按钮将再次出现。

经过这一切,我们的第二个项目就完成了!

摘要

为了回顾我们在本项目中涵盖的内容,我们向资产目录中添加了图像并在我们的代码中访问它们。然后,我们创建了三个单独的文件来保存我们需要的元素——一个用于保存唱片机盒子;一个用于保存旋转唱片、移动的臂和动态按钮;还有一个用于编写代码以访问声音文件。一旦创建了这些元素,我们就将它们合并到一个视图中,以创建一个唱片和动画唱片机。

在下一章中,我们将继续通过探索颜色来使用 Swift 动画。我们将创建一个简单的项目,显示各种图像,然后使用 hueRotation 改变图像的颜色,以显示万花筒效果。我们还将探讨如何双向传递数据到另一个视图,这比使用 @State 属性包装器提供了更多的灵活性。

第五章:动画彩色万花筒效果

在本章中,我们将探讨如何使用名为 hueRotation 的修改器来动画化颜色,其中“色调”指的是对象的颜色,“旋转”指的是正在旋转或动画化的颜色。我们将创建一个简单的项目来显示各种图像,然后使用 hueRotation,我们可以改变或移动图像的颜色,使其呈现出类似万花筒的效果。

除了 hueRotation,我们还将探讨在这个项目中的一些其他重要概念。

我们将第一次使用选择器视图,正如其名称所暗示的那样;它允许用户从各种选项中进行选择,然后这些选项可以显示在屏幕上。

此外,我们还将看看如何双向传递数据到另一个视图。如果你还记得第三章中的移动圆圈项目,它是在一个文件中构建的,然后在一个视图 ContentView 中,而在第四章中,我们使用几个文件构建了唱片机,然后将这些文件组合在 ContentView 中。在这两个项目中,我们都没有从文件到文件或视图到视图传递任何数据;视图包含了一些只需要在主 ContentView 中调用的代码块。但在这个项目中,我们将看到如何使用一个称为 @Binding 的特殊属性包装器在不同视图之间传递数据。

因此,在我们深入之前,让我们先列出目标:

  • 添加绑定变量和图像

  • 添加选择器控件和使用 ForEach 视图

  • 添加变量和背景颜色

  • 添加图像视图和使用 hueRotation 修改器

技术要求

你可以在 GitHub 上的 Chapter 5 文件夹中找到完成的项目和我们将使用的图像,网址为 github.com/PacktPublishing/Animating-SwiftUI-Applications

添加绑定变量和图像

好的,让我们开始吧。所以,创建一个新的 Xcode 项目,给它起个名字,然后我们就可以开始了。

我们将首先向项目中添加一个新的 SwiftUI 文件,我将称之为 selectedImage,并将其类型设置为 String。我们将在这个变量前加上 @Binding 包装器,如下所示:

struct ImagePickerView: View { 
    @Binding var selectedImage: String 
    var body: some View { 
        Text("Hello World") 
    } 
} 

@Binding 属性包装器用于在真实来源(例如,父视图中的状态属性)和依赖于该状态的视图之间创建双向绑定。在这个例子中,ImagePickerView 是真实来源,因为它在这里是父视图。@Binding 包装器允许视图读取和写入值,同时确保对值所做的任何更改都会传播回原始的真实来源。这就是视图如何在真实来源更改时自动更新和重新渲染,而无需手动传递新值并刷新视图。

现在让我们将需要的图片添加到项目中。你可以在 GitHub 仓库中找到它们——只需下载本章的资源。共有五张图片:ornamentlandscapedogdicecat。打开资产目录,将这五张图片拖放到目录中(就像我们创建唱片机时做的那样)。

确定了图片之后,让我们创建一个字符串数组来存储图片的名称。以下是代码,它被放置在@Binding属性下:

struct ImagePickerView: View { 
    @Binding var selectedImage: String 
    let images: [String] = ["ornament", "landscape", "dog", 
      "dice", "cat"] 

    var body: some View { 
        Text("Hello World") 
    } 
} 

要创建数组,我们首先给它一个名字,images,然后在方括号内使用String关键字,然后在另一对方括号中填充images数组,包含我们想要使用的五个String元素,即图片的名称。每个字符串之间用逗号分隔。

现在images数组已经准备好了,让我们添加一个选择控件,看看我们如何使用ForEach视图遍历这个数组,将每个字符串名称存储在选择视图中。

添加选择控件和使用ForEach视图

SwiftUI 为我们提供了足够多的预构建控件,以帮助构建既美观又实用的用户界面。一个有用的控件是用于向用户提供选项列表的选择视图,它可以提供你所需要的显示在应用中的任意数量的值。选择视图是一个允许用户从选项列表中选择一个项目的视图,通常与一个Binding变量一起使用,该变量将存储当前选中的值。

我们将使用forEach视图遍历images数组,并填充选择视图。在body属性中工作,我们首先添加PickerView,然后在选择视图中添加一个ForEach视图:

struct ImagePickerView: View { 
    @Binding var selectedImage: String 
    let images: [String] = ["ornament", "landscape", "dog", 
      "dice", "cat"]
    var body: some View { 
        Picker("", selection: $selectedImage) { 
            ForEach(images, id: \.self) { value in 
                Text(value) 
                    .foregroundColor(.white) 
            } 
        }

好吧,这里有很多内容,所以让我们分解一下。不过,快速提醒一下:不要担心在ImagePickerView下面的预览结构;那将显示一个错误,但我们很快就会修复它。

对于选择视图的第一个参数,你会注意到有一个空的String值。这个字符串将允许你为选择视图提供一个标签,如果你认为需要的话;然而,在这里我们不需要它,所以我们留空了String参数。在下一个参数,称为selection的参数中,selectedImage绑定属性将被放置,并且这个属性将绑定到ContentView中的State属性。我们还没有在ContentView中添加任何State属性,但我们很快就会这么做。

现在这个ImagePickerView文件中的selectedImage属性将双向连接到ContentView中的State属性。这种连接允许当任一结构中的值发生变化时,这两个结构之间的视图立即刷新。

语法要求我们在属性前放置美元符号($),以便告诉 SwiftUI 这个属性是一个绑定属性,可以连接到ContentView(事实来源)并更新视图。

下一行代码是ForEach视图。我们想要做的是遍历images数组,并在Picker视图中显示该数组中所有的 String 标签。为了遍历字符串数组,它们需要有一种某种 ID 来唯一标识这个数组中的每个元素。由于它们都有不同的名称,为什么不使用每个元素的名称,这些名称已经构成了唯一的 ID 呢?为了将每个图像的名称用作循环的 ID,我们使用了在images数组之后放置的\.self语法。

这段代码中的value关键字是循环变量;它将存储ForEach视图遍历的images数组中的每个元素。这个循环变量可以命名为任何你喜欢的名字,但在这个例子中我将其命名为value,因为这最有意义(它暂时存储数组中每个元素的值,一次一个)。

现在看看ForEach视图体内的代码,我们只需要添加一个包含value变量的Text视图。这将显示PickerView中的images数组中的所有内容。接下来,我们使用foregroundColor修饰符将文本颜色更改为白色。我们这样做是因为当我们进入ContentView文件时,我们将屏幕的背景设置为黑色;这样我们就能在黑色背景上看到白色的字母。

这样就完成了Picker视图的功能;现在让我们通过在视图的闭合括号后添加一些修饰符来样式化Picker视图的外观:

        }.pickerStyle(WheelPickerStyle())         
        .frame(width: 300, height: 150)
        .background(Color.red.colorMultiply(.blue))
        .cornerRadius(20)
        .shadow(color: .white, radius: 5, x: 0, y: 0 )

让我们看看每个使用的修饰符:

  • 我添加的第一个修饰符是pickerStyle(),它用于更改Picker视图的外观。我们可以从四个内置样式中选择来使用pickerStyle()修饰符来样式化 Picker。以下是可以用的样式:

    • DefaultPickerStyle(): 系统根据平台和当前上下文自动选择的默认样式。这将以菜单风格向用户展示选项。

    • PopUpButtonPickerStyle(): 这是一种弹出按钮样式选择器,常用于 macOS。这将以按钮风格向用户展示选项。

    • WheelPickerStyle(): 一种轮式选择器,常用于 iOS。这将以轮式风格向用户展示选项。

    • SegmentedPickerStyle(): 一种分段控制样式选择器,常用于 iOS、watchOS 和 tvOS。这将以分段按钮的风格向用户展示选项。

我们为这个项目选择了WheelPickerStyle

  • 第二个修饰符是frame修饰符,它设置了选择器控制的尺寸。在这里,我们将尺寸设置为300点宽和150点高。

  • 接下来是background修饰符,它将选择器的背景颜色设置为红色。然后colorMultiply通过将红色实例的 RGB 值与另一种颜色(在这种情况下是蓝色)相乘来修改红色颜色实例。结果是深紫色。

  • 之后,我们使用了cornerRadius修饰符将选择器的角落圆滑到 20 点。

  • 我们使用 shadow 修改器完成选择器的样式。这将添加一个半径为 5 点的白色阴影,并且当我们将背景色改为黑色时将变得可见。

现在,让我们回到我们的代码正在遇到的错误,看看我们如何确保应用程序可以干净地构建。错误表示我们缺少 ImagePickerView 调用的一个参数。这是真的,因为我们向 ImagePickerView 结构体中添加了一个名为 selectedImageBinding 属性,并且由于 Preview 结构体引用了 ImagePickerView,它也需要在自己的结构体中使用那个 Binding 变量才能干净地构建。

为了解决这个问题,我们需要进入文件底部的 Previews 结构体,并将第一行代码更改为以下内容:

ImagePickerView(selectedImage: .constant("ornament")) 

通过使用 constant 函数,错误就会消失。constant 函数可以接受任何我们想要的值,只要它是 String 类型,因为 selectedImage 属性的数据类型就是 String。我正在使用 images 数组中的 "ornament" 字符串,这个字符串将在选择器中显示。

现在代码构建干净,结果应该看起来像这样:

图 5.1:选择器视图

图 5.1:选择器视图

拨动选择器轮盘,你可以看到 images 数组中的所有字符串名称。

现在我们已经完成了 ImagePickerView 文件,让我们继续设置 ContentView 文件。

添加变量和背景颜色

现在进入 ContentView 文件,我们在这里的第一个任务将是添加几个变量和一个常量。让我们从添加一个变量开始,该变量将绑定到 ImagePickerView 文件中的 selectedImage 绑定属性。

为了做到这一点,我们需要创建一个 State 变量,它的数据类型需要与 selectedImage 变量相同,即 String 类型。我们可以给它与变量相同的名字,selectedImage,这样你就知道这个变量是双向链接到 ImagePickerView 文件中的变量。

ContentView 结构体中添加此代码,在顶部:

    @State private var selectedImage: String = "ornament" 
    @State private var shiftColors = false 
    let backgroundColor = Color(.black) 

selectedImage 变量被设置为名为 ornament 的 String 值。接下来是一个名为 shiftColors 的变量,用于跟踪动画,其设置为 false。最后,我们有一个常量来保存背景颜色,它将是黑色。

进入 body 属性,让我们添加一个 VStack,它将负责垂直组织用户界面需要的三个视图:一个背景,将是黑色;一个 Image 视图,用于显示用户选择的照片;以及一个对我们在 ImagePickerView 文件中创建的 ImagePickerView 的调用,允许用户选择照片。

要完成所有这些,首先将一个 VStack 添加到 ContentViewbody 属性中:

var body: some View {
VStack {
       }
}

接下来,将 ImagePickerView 添加到 VStack 中,并在其上应用 frame 修改器,如图所示:

var body: some View {
    VStack {
          ImagePickerView(selectedImage: $selectedImage)
          .frame(width: 350, height: 200)
        }

为了着色背景,我们需要一个 ZStack,以便它可以将颜色覆盖整个屏幕。在 VStack 中添加此代码:

  ZStack {
        backgroundColor
           .scaleEffect(1.4)
       }
}

如我们之前所见,此代码将背景着色为黑色,通过使用scaleEffect修饰符并传入1.4的值,黑色背景将拉伸以覆盖整个屏幕。

现在背景已经设置好了,让我们添加一个Image视图来显示图片,然后开始动画一些颜色。

添加 Image 视图和使用色调旋转修饰符

背景设置完毕,所有变量都已就绪,现在让我们添加一个ImageView来显示图片,并将其hueRotation修饰符添加到其中。

scaleEffect修饰符之后立即添加以下代码:

Image(selectedImage).resizable().padding(20).frame(width: 
  400, height: 400) 
    .hueRotation(.degrees(shiftColors ? 360 : 0)) 
    .animation(Animation.easeInOut(duration: 
      2).delay(0.3).repeatForever(autoreverses: true), 
      value: shiftColors)
    .onAppear() {
        shiftColors.toggle()
                }
            } 
            ImagePickerView(selectedImage: $selectedImage) 
                .frame(width: 350, height: 200) 
        }.background(backgroundColor) 
        .edgesIgnoringSafeArea(.bottom) 
    } 
} 

在这里,我们使用Image视图在屏幕上显示图片,调整其大小并添加一点内边距。然后我们将图片视图的框架设置为400 x 400的大小。

接下来是hueRotation修饰符。色调旋转是一种图像处理效果,通过旋转色调颜色轮中的颜色来调整图像的色调,旋转角度由指定值决定。它通过改变每个像素的色调来改变图像的整体色彩调。

它执行此操作的速度由传递给其参数的值决定。如果你有一个包含许多不同颜色的图片,那么hueRotation将循环并通过旋转所有这些颜色,创建一种类似万花筒的色彩效果。

看一下hueRotation参数,它需要一个表示度的值,这是应用于此视图中颜色的旋转量,由degrees函数处理。在degrees函数内部是一个三元运算符,它将选择两个选择之一:当shiftColors变量为true时,值为360;当它为false时,值为0

接下来,添加animation修饰符以实现颜色变化动画效果。它使用easeInOut时间曲线,持续时间为 2 秒,动画之间有 0.3 秒的延迟来完成动画。

然后,我们使用onAppear修饰符,它在视图出现时立即运行其体内的代码。这是一个通过切换shiftColors变量的相反值来触发动画的完美位置。

之后,我们为ImagePickerView设置了350 x 200的框架宽度和高度,并使用我们的background变量着色了VStack的背景。我们还使用了edgesIgnoringSafeArea来处理底部边缘,这样背景颜色就会延伸到屏幕下方。我们希望整个屏幕都是黑色,我们的视图在上面,这段代码正是这样做的。

猜猜看?所有这些都完成后,项目就完成了!它应该看起来是这样的:

图 5.2:完成的项目

图 5.2:完成的项目

尝试运行项目——你会看到你选择的图片中的所有颜色将在 2 秒内改变和旋转。

摘要

优秀的工作,完成了这个项目!在完成的过程中,你看到了如何使用Binding属性来在视图之间绑定数据,Picker视图,一个具有不同样式的视图,允许用户选择图片或其他数据,最后是hueRotation修饰符,它可以旋转颜色。

在下一章中,我们将探讨将图像切割成不同部分的过程,然后使用rotationEffect修饰符旋转这些单独的部分,以创建一个女孩在秋千上的动画。

第六章:动画秋千上的女孩

欢迎来到下一个项目。在这个项目中,我们将对图像的一部分进行动画处理,具体是一张女孩在秋千上的图像,并使这些单独的部分以自然的方式移动。

为了做到这一点,我们将研究一种动画技术,该技术使用任何主题的图像或矢量文件,然后将该图像切割成多个部分以单独动画它们。我们还将研究一个新的修改器,称为mask,它用于设置视图的不透明度。

让我们看看在这个项目中我们将学习什么:

  • 收集和切割图像

  • 添加动画变量

  • 将图像添加到场景中

  • 使用mask修改器

技术要求

你可以从 GitHub 上的第六章文件夹下载资源和完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

收集和切割图像

在这个项目中,我将使用一些简单的图形,包括一张女孩的图像、一个叶状背景和一根树枝。正如我提到的,我们将对场景的两个部分进行动画处理:秋千和女孩的小腿。

首先你需要做的是按照技术要求部分所述下载图像,然后将图像切割成各个部分。为此,我将简单地使用 Mac 的预览应用,这是一个已经安装在你 Mac 电脑上的免费应用。在这个应用中,有一个叫做标记的选项,如下所示:

图 6.1:访问标记工具

图 6.1:访问标记工具

点击标记将打开一系列有用的编辑工具,我们可以使用这些工具以独特的方式转换图像。用于切割图像的最佳工具之一是套索选择工具,它允许我们在图像的某些或全部部分周围绘制选择框,然后将其裁剪出来或复制到另一个窗口。你可以在以下下拉菜单中找到这个工具:

图 6.2:访问套索选择工具

图 6.2:访问套索选择工具

使用它时,点击并拖动你的光标围绕任何对象,完成后连接起点和终点。我们想要将套索拖动到女孩的小腿周围,就在膝盖关节上方,如下所示:

图 6.3:使用套索选择工具 [来源:由 brgfx 创建的剪贴画矢量图 - www.freepik.com]

图 6.3:使用套索选择工具 [来源:<a href=”www.freepik.com/vectors/clip-art”>由 brgfx 创建的剪贴画矢量图 - www.freepik.com]

使用leg选择腿部后,你的图像将看起来像这样:

图 6.4:使用套索选择工具分离腿部

图 6.4:使用套索选择工具分离腿部

这张图像还可以,但有一个问题。这张图像包含一个在场景中看起来不太好的背景;我们只想得到没有背景的腿部。我发现移除背景的最佳方式是使用一个有用的免费在线工具removebg,你可以通过这里访问:www.remove.bg/upload。只需将图像拖放到该网站上,它就会识别背景并将其移除。

到目前为止,我们现在有一张女孩腿部的图像(尽管我们的动画包括两条腿,一张图像就可以很好地代表女孩的两条腿)。

在我们的项目中接下来,我们还想有一根绳子,我们将用它将秋千系在我们的背景图像的树上。然而,如果你看图 6.3,绳子有点短,不符合我们的需求。为了得到额外的绳子部分,我使用套索选择工具围绕绳子,复制它,然后创建了一个新的图像,接着使用removebg(就像我们处理腿部一样)移除了背景。图 6.5是结果;这是我们将在场景中用来延长现有绳子的:

图 6.5:绳子

图 6.5:绳子

接下来,我寻找了一个合适的背景,它是一棵树,树下有一些草地和天空。这是我们将会使用的背景(它原本有一个秋千;然而,我不想要那种秋千,所以我使用了一些其他软件 Affinity Photo 将其移除)。现在我们有一棵树,我们可以在树枝上系一个秋千:

图 6.6:我们的背景图像

图 6.6:我们的背景图像

[来源:www.istockphoto.com/vector/tire-swing-in-autumn-gm165677003-10337433?phrase=tyre%20swing]

我还觉得这棵树有点稀疏,所以我找到了一些可以放置在树枝上的树叶,它们也会隐藏绳子的顶部:

图 6.7:我们的树叶图像

图 6.7:我们的树叶图像

当你尝试分割图像并制作动画的各个部分时,你可能发现你想要比 Mac 的预览应用更好的工具。你也可能发现,尽管removebg在大多数情况下都有效,但它可能不是在所有情况下都有效。如果是这样,你可以使用像 Photoshop 这样的专业软件;然而,那可能很昂贵。相反,你可以尝试我之前提到的我最喜欢的图像编辑软件 Affinity Designer 和 Affinity Photo。它们的价格非常合理,并且和 Photoshop 一样强大,但它们更加用户友好。如果你想要学习如何在 iOS 应用中操作图像而不需要支付像 Photoshop 那样的年费订阅,那么你绝对应该看看它们。

一旦我们收集到了摇摆场景中女孩所需的全部部件,我们就可以开始在 Xcode 中组装它。因此,创建一个新的项目——我将其命名为 Girl On A Swing——然后打开资产目录,并将所有图片按照之前的方式拖入其中。

现在,我们可以在 ContentView 文件中开始编码;这是我们在这个项目中需要的唯一文件,因为这个项目需要的代码非常少。

添加动画变量

正如我们之前所做的那样,我们首先添加了跟踪我们想要动画的不同部件所需的属性。在我们的例子中,我们需要三个属性:

  • 一个用于女孩图像的属性,恰当地命名为 girl

  • 一个用于左腿的属性,命名为 leftLeg

  • 一个用于右腿的属性,命名为 rightLeg

尽管我们使用一个图像来表示右腿和左腿,但为了使动画工作,我们仍然需要两个独立的腿属性,因为腿会在不同的时间和速度下移动。

所有这些属性都将被设置为 State 属性,因此当它们的值发生变化时,视图将立即更新。将以下代码放入 ContentView 文件中,在 body 属性之上:

    @State private var girl = false
    @State private var leftLeg = false
    @State private var rightLeg = false

如同往常,所有属性都设置为 false,这样动画就不会开始,直到它们被更改为 true

我们还将添加一个第四个属性,我将其命名为 fadeOutRope。这并不是严格必要的,但它给了我一个机会向您展示如何使用 mask 修饰符,该修饰符通过改变视图的不透明度来遮罩视图;如果我们传入一个渐变色,我们就可以使绳子在达到树枝时逐渐消失。以下是您需要在之前的属性下添加的属性:

let fadeOutRope = Gradient(colors: [.clear, .black])

Gradient 函数需要一个颜色数组才能工作,所以我提供了两种颜色:一种 clear 颜色(或无颜色),以及一种 black 颜色。这两种颜色将混合到绳子图像中,使顶部部分逐渐消失。正如我所说的,这并不是严格必要的;然而,我认为使绳子的顶部逐渐消失会更好,因为它并没有连接到我们使用的图像中的树枝,因此我们希望将其稍微混合一下,使其看起来像是连接在树的某个地方。稍后,我们将使用 mask 修饰符,添加一些树叶的图像来帮助隐藏绳子的顶部,使场景看起来更加自然。

将图像添加到场景中

我们需要添加四个图像到我们的场景中,我们将按照以下顺序添加:

  • 背景

  • 右腿

  • 女孩

  • 左腿

那么,让我们开始吧!

添加背景

我们将在 ZStack 内部添加背景,这将是文件中的第一个视图,因此所有后续的视图都将放置在这个视图之上。以下是您需要的代码:

var body: some View {
        ZStack {
            Image("tree").resizable()
                .frame(width: 550, height: 900)
               }
            }

ZStack 中,我们调用 Image 初始化器并传入 tree 字符串。接下来,我们使用 resizable 修饰符来调整树的大小,然后将背景的 frame 设置为宽度 550 和高度 900,这样背景图像就可以延伸到任何尺寸的 iPhone 的边缘。

添加右腿

当涉及到添加rightlegleftleggirl属性时,我们需要另一个ZStack,这样我们就可以将所有视图叠加在一起,然后使用offset修饰符将它们移动到所需的位置。

要将右腿添加到场景中,输入以下代码:

 ZStack {
    ///right leg
    Image("leg").resizable().aspectRatio(contentMode: .fit)
        .rotationEffect(.degrees(rightLeg ? -20 : 50),
          anchor: .topTrailing )
        .scaleEffect(0.12)
        .offset(x: -448, y: 92)
        .animation(Animation.easeInOut(duration: 
          1).delay(0.09).repeatForever(autoreverses: true),
          value: rightLeg)
        .onAppear() {
            rightLeg.toggle()
        }
     }

首先,Image初始化器传入我们要显示的图像名称;这里,是"leg"。记住,我们使用这张图像来代表左右腿(由于这是一个 2D 场景,我们可以使用单张图像;我们没有图像周围的 3D 视图,因此我们实际上看不到每条腿的任何差异)。

接下来,我们将使用resizable修饰符并将纵横比设置为fit。使用fit选项将图像内容调整到使用所有可用的屏幕空间,包括垂直和水平方向。

下一行代码设置了腿的rotationEffect。当rightLeg属性为true时,右腿将旋转-20度,当它为false时,将旋转50度。

注意到anchor参数吗?锚点是指图像将围绕其旋转的点。当我们考虑人类(以及动物)时,我们的锚点是我们的关节。我们弯曲和移动都是从这些关节开始的,我们的支点。在我们的项目中,通过使用topTrailing选项,腿将围绕膝盖关节旋转。

注意

如果你将一个点(.)输入到你的代码中,你将可以看到 Xcode 为你提供的关于锚点的其他选项。

之后,我们需要将图像缩放到可用的尺寸。就目前而言,它对于屏幕来说太大,因此通过使用scaleEffect修饰符,我们将它缩小到原始大小的 12%。对于我们这个场景来说,这是一个很好的匹配。

下一行代码使用offset修饰符将右腿放置在我们需要的确切位置。它需要放置在女孩图像的膝盖关节处,我计算出的值是x-轴的-448 和y-轴的 92。到达这些坐标实际上只是试错的过程。例如,如果你想将部分向上移动到屏幕上,为y位置添加一个较小的数字,如果你想将图像向右移动,为x位置添加一个较大的数字。

最后,我们来到了动画部分,其中大部分我们都已经见过。在这里,animation修饰符负责移动腿,持续时间为 1 秒,这是动画完成所需的时间。此外,使用轻微的.09秒延迟将使动画看起来更加随机,因为我们将会将左腿设置成不同的延迟。

此外,我们希望动画无限重复,或者至少直到用户停止应用程序,这就是为什么使用repeatForever函数的原因。autoReverse参数的值为true,因为我们希望动画在一个方向上前进,完成,然后反转并继续在相反方向上。此外,animation修饰符需要一个属性作为其value参数,为此,我们将rightLeg属性传递给它。

代码的最后部分是onAppear修饰符。这个修饰符用于在视图出现在屏幕上时运行代码,因此命名为onAppear。在body中,我们切换rightLeg属性为true以激活动画。

现在,屏幕上有一个来回摆动的腿,这并不是我们想要的外观,所以现在让我们添加女孩,然后是左腿。

添加女孩

要将女孩添加到场景中,请输入以下代码:

 Image("Girl").resizable().aspectRatio(contentMode: .fit)
                .scaleEffect(0.25)
                .offset(x: -300, y: 0)

这段代码与添加腿的代码类似:我们使用了Image初始化器,传递了Girl图像,使其可调整大小,并将aspectRatio设置为fit。然后我们只需要两个额外的修饰符来完成这个图像:scaleEffect,它将图像缩放到适合场景的大小,以及offset,用于将女孩图像放置在xy轴的正确位置(使用x的值为-300y的值为0将女孩放置在大约屏幕中间)。

添加左腿

最后,为了完成女孩图像,她需要一个左腿:

///left leg
    Image("leg").resizable().aspectRatio(contentMode: .fit)
        .rotationEffect(.degrees(leftLeg ? -45 : 30),
          anchor: .topTrailing)
        .scaleEffect(0.12)
        .offset(x: -455, y: 90)
        .animation(Animation.easeInOut(duration:
          0.4).delay(1).repeatForever(autoreverses: true),
          value: leftLeg)
        .onAppear() {
            leftLeg.toggle()
        }

这条左腿的代码与右腿的代码几乎相同,但我们使用了leftLeg属性并更改了一些值。例如,这条腿的duration属性为0.4delay属性为1秒。正如我之前提到的,通过添加与其他图像不同的持续时间以及延迟,我们可以稍微随机化摆动效果,这样两条腿就不会同时上下摆动。这将使动画的摆动动作更加自然。

将图像组合在一起

在此代码到位后,我们已经完成了摆动场景中女孩的拼接,但如果查看预览,它只显示了背景:

图 6.8:只有背景的动画

图 6.8:只有背景的动画

我们刚刚编写的两条腿和女孩在哪里?嗯,它们都在那里,但它们还没有定位在背景上;它们都在屏幕的一侧。这是因为我们根据女孩定位了腿,但没有根据背景场景定位完成的图像。为了做到这一点,让我们从第二个ZStack花括号中出来,并添加以下代码:

        .offset(x: 25, y: 0)
        .rotationEffect(.degrees(girl ? -30 : -45), anchor:
          .top)
        .animation(Animation.easeInOut(duration:
          1).delay(0.09).repeatForever(autoreverses: true),
          value: girl)
     .onAppear() {
            girl.toggle()
        }

这段代码的功能如下:

  • 第一行将完成的Girl图像偏移一小段距离,使其居中。

  • 第二行设置了完成图像的旋转参数,即我们希望女孩来回摆动的幅度。如果girl属性为true,女孩图像将向右摆动(-30),如果为false,图像将向左摆动(-45);这创建了摆动动画。支点放置在女孩图像上方;这是我们希望图像围绕旋转的点。

  • 第三行将动画添加到完成图像中,持续时间为1秒,轻微延迟为0.09秒。

有了这段代码的完成,你现在可以看到女孩和她的腿。然而,还有一个问题:女孩和她的腿的图像看起来太小了,而且腿没有连接到女孩身上:

图 6.9:我们的动画,图像部分未连接

图 6.9:我们的动画,图像部分未连接

为了修复这个问题,我们需要在第一个ZStack的闭合括号之后添加一行代码:

    .frame(width: 950, height: 900)

这段代码设置了ZStack内部所有视图的宽度和高度,使其与背景成比例。以下是结果:

图 6.10:我们的动画,图像部分(几乎)连接在一起

图 6.10:我们的动画,图像部分(几乎)连接在一起

好吧,运行一下这个应用程序看看它会做什么。这里同时有三个动画在进行;女孩在来回摆动,每条腿都在单独地上下踢动,如图所示:

图 6.11:动画(部分几乎连接在一起)

图 6.11:动画(部分几乎连接在一起)

现在一切都在动画中...但是看看摆动的顶部在图 6.11中。绳索在空中结束。让我们使用资产目录中的绳索和树叶图像来修复这个问题。

使用遮罩修饰符

绳索没有连接到任何东西——我们怎么修复它呢?嗯,很简单:通过添加两根绳索并使用mask修饰符帮助它们在顶部逐渐淡出。

在左腿的onAppear修饰符的闭合括号之后直接添加以下代码:

    //MARK: - ROPE
    ///right side rope masked
    Image("rope").resizable().aspectRatio(contentMode:
      .fit)
        .mask(LinearGradient(gradient: fadeOutRope,
          startPoint: .top, endPoint: .bottom))
        .frame(width: 57, height: 80)
        .offset(x: -189, y: -100)
    ///left side rope masked
    Image("rope").resizable().aspectRatio(contentMode:
      .fit)
        .mask(LinearGradient(gradient: fadeOutRope,
          startPoint: .top, endPoint: .bottom))
        .frame(width: 57, height: 80)
        .offset(x: -228, y: -108)

我们需要两根绳索,因为摆动有两部分,一根是右边的部分,另一根是左边的部分。因此,我添加了两块代码,每部分一个。

添加绳索的代码并没有什么新意;我们只是使用Image初始化器传入我们的绳索图片,并将纵横比设置为fit。但这里的新颖之处在于我们使用了mask修饰符。mask修饰符将一个遮罩视图应用到被调用的视图上。遮罩视图定义了应用到的视图的可见区域,任何超出遮罩视图框架的视图部分将不可见。

mask 修饰符接受一个参数,即用作遮罩的视图。如果我们像这里一样传递 gradient,则可以逐渐淡出向上延伸的绳索。这就是 LinearGradient 函数和我们的 fadeOutRope 属性发挥作用的地方;后者包含一个渐变颜色数组(实际上是两种颜色,透明和黑色),我们将其传递给 LinearGradient 函数。

我们还需要考虑 startPoint(你想要开始混合下一个颜色的地方)和 endPoint(你想要结束一种颜色并开始另一种颜色的地方)。在我们的代码中,渐变的 startPoint 在绳索图像的顶部,而 endPoint 在绳索图像的底部。我们想要起点在顶部的原因是,这样它将有一个清晰的颜色,这样我们就可以在绳索向上移动时将其淡出。

在代码的最后两行中,我们设置了左右绳索图像的大小并将它们偏移,以便它们完美地覆盖在吊绳上。这是添加带有 mask 修饰符的两个绳索图像的结果:

图 6.12:添加遮罩修饰符后的我们的动画

图 6.12:添加遮罩修饰符后的我们的动画

如果你现在运行这个应用,你会看到绳索向上延伸并在顶部淡出。这看起来好多了,因为现在绳索看起来好像就在树枝附近。

为了进一步提高这个项目,我们可以在场景中添加树叶图像来部分遮挡绳索的末端。为此,在第一个 ZStack 的最后添加这段最后的代码:

Image("leaves").resizable().aspectRatio(contentMode: .fit)
        .frame(width: 460, height: 400)
        .rotationEffect(.degrees(-10), anchor: .trailing)
        .offset(x: -50, y: -180)

这是我们已经使用过的熟悉代码,向场景中添加树叶,设置合适的 aspectRatioframe 大小,将其旋转到正确的角度,即与树枝垂直的角度,并最终使用 offset 修饰符将其定位在 x 和 y 轴上。

现在顶部的绳索被树叶部分遮挡,给人一种它在树枝上某个地方系起来的效果:

图 6.13:我们的完成动画

图 6.13:我们的完成动画

就这样,项目就完成了。运行它,看看你的想法。

摘要

在这个项目中,我们学习了如何从一个图像中剪出各个关节部分,在代码中使用这些新图像,并以不同和有趣的方式对它们进行动画处理。我们还使用了之前使用过的修饰符,包括 rotationscaleoffset,以及一个新的修饰符 mask

有方法可以将这个项目进一步发展。如果你感到好奇,看看你是否可以从中剪下手臂和头部,然后也给他们添加一些动画。调整参数,以便每个部分都能自然地移动。也许你可以让手臂在摆动前后稍微在肘部旋转。如果你想的话,可以添加一个按钮来开始和停止动画。主要的是,只是实验并享受乐趣!

在下一个项目中,我们将探讨三个不同的旋转轴,xyz,并创建一个动画,使用齿轮和链条来旋转风扇,类似于自行车链条带动齿轮旋转的情况。

第七章:构建一系列皮带和齿轮

在这个项目中,我们将探索旋转,特别是如何使用 rotation3DEffect 修改器在 xyz 轴上旋转对象。我们将通过动画一系列齿轮和皮带来实现这一点,最终移动风扇叶片。

在创建此项目的过程中,我们还将探索如何使用组和祈使标记来使您的代码更有组织性,以及 zIndex 属性,该属性会改变视图的深度。

在本章中,我们将涵盖以下主题:

  • 动画我们的第一个圆形齿轮

  • 使用阴影添加蜗轮齿轮

  • 使用行军蚂蚁效果创建齿轮皮带

  • 动画齿轮轴图像

  • 动画风扇图像

  • ContentView 中整合一切

技术要求

您可以从 GitHub 上的 Chapter 7 文件夹下载资源和完善的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

动画我们的第一个圆形齿轮

首先,让我们创建一个新的项目,我将它命名为 Gears and Belts。然后,通过将图像拖放到 Swift 的资产库中添加项目的图像(您可以在 技术要求 部分提供的 GitHub 仓库中找到这些图像),我们使用的图像有 singleGeardoubleGearwormGearmotorshaftfangoldBackground

在本节中,我们将首先围绕 z- 轴动画齿轮图像,因此让我们创建一个 SwiftUI 文件来处理创建我们需要的所有齿轮(正如我们在之前的项目中做的那样,我们将为项目的每个元素在单独的文件中工作,然后将其拼接到 ContentView 中以创建完成的动画)。

要做到这一点,请转到 GearView 并按 创建

现在,我们可以通过添加制作和动画齿轮图像所需的变量来填充此文件。让我们从一个 State 变量开始,该变量跟踪动画状态,状态是动画是否在运动。将此 @State 变量添加到文件中:

@State private var rotateGear = false

这个变量将帮助跟踪齿轮图像是否在旋转,所以我将其命名为 rotateGear。它也被设置为 false,这意味着动画将处于非活动状态,直到这个变量被更改为 true

下一个变量将是 String 类型,我们可以用它来设置齿轮图像的名称:

var gearImage: String = ""

如果您回顾资产库,您会看到您放置了三种不同类型的齿轮:一个单齿轮、一个双齿轮和一个蜗轮齿轮。因此,当需要使用所有这些文件在 ContentView 中时,这个 gearImage 变量将帮助节省时间,因为我们只需要输入我们想要使用的齿轮的名称。

接下来,我们需要能够设置齿轮的大小;这里有一个变量来处理这个任务:

var gearWidth: CGFloat = 0.0

你可能想知道为什么我们要设置齿轮的宽度而不是高度。好吧,我们使用的齿轮图像是圆形的(除了蜗轮齿轮);正如你可能知道的,圆形只需要一个维度,即宽度或高度,因为圆形的直径总是相同的,无论测量哪个维度。

继续添加这个文件需要的变量,让我们添加另一个变量来指示齿轮将旋转的度数:

var gearDegrees: Double = 0.0

这个变量被称为gearDegrees360的值将使齿轮图像旋转一周,但当前它被初始化为0.0

我们还将围绕屏幕放置齿轮,因此我们需要设置一些变量来处理这些齿轮的位置。我们将使用其中两个来设置XY位置:

var offsetGearX: CGFloat = 0.0
var offsetGearY: CGFloat = 0.0

接下来,我们需要能够将齿轮以及后来的皮带旋转到与周围环境不同的方向,所以让我们添加一个变量来负责设置这个值:

var rotateDegrees: Double = 0.0

然后,我们将添加一个变量来设置旋转齿轮的持续时间,即完成一次旋转所需的时间:

var duration: Double = 0.0

持续时间变量被设置为0,但当它被设置为例如7这样的值时,这意味着齿轮需要 7 秒钟才能完成一次完整旋转。

最后,对于这个文件,我们还需要三个变量来设置齿轮的xyz轴位置:

var xAxis: CGFloat = 0.0
var yAxis: CGFloat = 0.0
var zAxis: CGFloat = 0.0

每个这些变量都将控制齿轮旋转的轴。轴是允许我们在三维空间中旋转对象的东西,这将有助于创建透视和场景中的深度效果。这些变量被适当地命名为xAxisyAxiszAxis

我们已经准备好了所有变量,现在让我们将这些代码添加到结构的主体中,并制作我们的第一个齿轮。首先,让我们添加一个ZStack来容纳我们需要的所有视图:

 ZStack {
        }

现在,在ZStack内部,我们可以使用Image初始化器,它将在屏幕上放置一个齿轮图像:

ZStack {
    Image(gearImage)
       }

记住,我们已经将前面的gearImage变量初始化为空字符串;这正是我们想要的,因为它允许我们传递代表资产目录中齿轮图像的不同字符串名称。

接下来,我们需要几个修饰符来调整齿轮图像的大小和位置,以及动画修饰符来使齿轮图像旋转。在Image(gearImage)下添加以下代码:

ZStack {
            Image(gearImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: gearWidth)
                .rotationEffect(.degrees(rotateGear ?                   gearDegrees : 0))
                .animation(Animation.linear(duration:                   duration).repeatForever(autoreverses: false),                   value: rotateGear)
                .rotation3DEffect(
                    .degrees(rotateDegrees),axis: (x: xAxis, y:                       yAxis, z: zAxis))
                .offset(x: offsetGearX, y: offsetGearY)
        }

让我们看看我们在这里使用的修饰符:

  • 首先,我们知道我们需要给图像赋予缩放的能力,这正是resizable修饰符的作用。

  • 然后,我们想要使用fit宽高比选项来约束齿轮图像的尺寸;正如我们之前看到的,这种模式会保留内容的宽高比,并指示对象应该缩放以适应可用空间,同时保持其宽高比。这意味着如果需要,对象将被缩小以适应其显示的空间,而不会扭曲其形状。

另一个选项是fill;这意味着对象将被缩放以填充其显示的空间,而不会扭曲其形状。对象的一些部分可能位于可见区域之外,但对象将保持其宽高比。不过,我们在这里没有使用这个选项。

  • 接下来,使用gearWidth修饰符设置图像的框架,该修饰符已初始化为0。通过这样做,我们可以传入任何所需的值来创建任何大小的齿轮。

  • 下一行调用rotationEffect修饰符,该修饰符将gearImage旋转一个我们传入的值。当rotateGear变量变为true时,此修饰符将仅旋转齿轮图像;否则,使用0的值,意味着不旋转。

  • 之后,我们添加animation修饰符,它获取一个linear动画。动画的持续时间将取决于duration变量中持有的值——在这里,我们将持续时间设置为repeatForever并将autoreverses设置为false。然后,我们有value参数,它接受我们想要动画化的变量并将其应用于视图。

  • 下一个修饰符很有趣,它被称为rotation3DEffect。这个修饰符会在给定的轴上绕三维视图旋转;旋转量将由它内部使用的degrees修饰符确定。为了理解视图将如何旋转,了解xyz轴在 iPhone 屏幕上的位置很重要。请看以下插图:

图 7.1:三个坐标轴

图 7.1:三个坐标轴

x轴在 iPhone 屏幕上从左到右延伸,y轴从上到下延伸,z轴从后向前延伸。这些轴是三个不同的空间维度平面,它们包含视图或对象,当一个对象旋转时,它会在一个或所有这些轴上旋转。查看rotation3DEffect修饰符,如果我们传递一个轴参数的值,视图将围绕该轴旋转指定的角度。

  • 回到代码,最后,齿轮图像需要放置在屏幕上的某个位置;为此,我们可以使用offset修饰符的帮助。此修饰符有两个参数,xy,其值将由offsetGearXoffsetGearY变量确定。

在所有修饰符都就绪后,我们只需要开始动画,所以让我们在 ZStack 的闭合括号末尾添加以下代码:

.onAppear() {
            rotateGear.toggle()
        }.shadow(color: .black, radius: 1, x: 0, y: 0)

onAppear修饰符,正如我们之前所看到的,当场景首次出现时,将运行其体内的代码。在代码中,我们希望将rotateGear变量切换为true以启动动画。我还为齿轮图像添加了一点点黑色阴影,这使得它在边缘处看起来更美观。

现在,尽管我们已经编写了所有这些代码,但在预览中仍然没有看到任何图像!让我们解决这个问题,并将一些值添加到Previews结构中,以便创建一个齿轮。在这个例子中,我们将使用双齿轮图像,并给它一个大小。将以下代码放置在GearView_Previews中,以便我们可以看到动画效果:

 GearView(gearImage: "doubleGear", gearWidth: 100, gearDegrees:    360, offsetGearX: 0, offsetGearY: 0, duration: 5)
            .previewLayout(.fixed(width: 200, height: 200))

这段代码用一些值填充了GearView结构。第一个参数使用资产目录中的doubleGear图像作为要显示的齿轮。接下来,gearWidthgearDegrees参数接收值以设置宽度和转动度。然后,对于offset参数,通过将它们设置为零,齿轮将保持在屏幕中间。最后,添加 5 秒的持续时间意味着齿轮将花费 5 秒完成一整圈的转动。

所有这些都准备好了,现在我们可以运行预览并检查到目前为止我们已经做了什么:

图 7.2:添加我们的第一个齿轮

图 7.2:添加我们的第一个齿轮

齿轮现在以每 5 秒一转的速度转动。

此外,请注意,我正在为预览窗口设置固定的宽度和高度,因为我们不需要全尺寸屏幕来显示一个小齿轮;200 x 200 就足够了。

现在,齿轮已经放置好,这个文件也完成了,任何时候我们想在项目的任何地方制作一个齿轮,我们只需要调用gearView结构并传递一些值来创建任何大小和屏幕上所需位置的齿轮。

然而,尽管做了所有这些工作,仍然有一个齿轮我们无法以任何有意义的方式转动,因为它不是圆形的,那就是资产目录中的蜗轮齿轮图像。在下一节中,我将解释什么是蜗轮齿轮,以及如何让它转动,或者至少让它看起来像是在转动。

使用阴影动画蜗轮齿轮

蜗轮齿轮看起来像带有螺旋螺纹的大螺丝,但没有螺丝头。它们用于需要强度作为重要因素的设备和机器中,因为它们非常耐用,可以承受很大的扭矩。这是一个典型的蜗轮齿轮:

图 7.3:蜗轮齿轮

图 7.3:蜗轮齿轮

如果我们将蜗轮齿轮图像添加到项目中,并像处理圆形齿轮一样动画化它,它将不起作用,仅仅是因为蜗轮齿轮图像不是圆形的。那么,我们如何让一个不规则形状的图像看起来像在真实世界中那样转动呢?

我们可以做到的是在蜗轮齿轮图像的亮面部分放置小的阴影矩形,并动画化这些矩形,这将产生一种图像正在转动或旋转的错觉。聪明,对吧?

首先,创建一个新文件,选择WormGearView。在这个文件中,在WormGear结构内部,我们首先将添加四个State属性,每个矩形需要一个:

    @State private var rect1 = false
    @State private var rect2 = false
    @State private var rect3 = false
    @State private var rect4 = false

现在,在我们的主ZStack中,让我们添加另一个ZStack来显示蜗轮齿轮图像:

 ZStack {
    ZStack {
                Image("wormGear").resizable().frame(width: 100,                   height: 75)
           }
        }

Image 初始化器声明了图像并将其调整到我们可以使用的宽度和高度。

接下来,我们将创建第一个需要放置在蜗轮齿轮光滑部分上的矩形。为此,添加以下代码:

HStack {
                    Rectangle()
                        .frame(width: 4, height: 40)
                        .foregroundColor(.black)
                        .cornerRadius(5)
                        .opacity(rect1 ? 0 : 0.3)
                        .offset(x: 2, y: rect1 ? 14 : -8)
                        .animation(Animation.easeInOut                           (duration: 0.5).repeatForever                           (autoreverses: true), value: rect1)
                        .rotationEffect(.degrees(-4), anchor:                           .top)
                        .onAppear(){
                            rect1.toggle()
                        }
        }

我们将矩形放置在 HStack 中,因为我们想将它们并排放置,从左到右穿过齿轮。我们还在使用之前使用过的熟悉修饰符。我们将矩形设置为黑色,并给它们轻微的圆角半径。矩形的透明度将取决于名为 rect1 的动画变量是 true 还是 false:如果 rect1 属性变为 true,我们将使用 opacity0.3 给矩形一点可见性;当 rect1 属性为 false 时,我们将移除所有透明度并隐藏它。

下一行代码使用 offset 修饰符将此矩形直接放置在左侧蜗轮齿轮的第一个光滑部分上。这个 offset 修饰符还负责在 y 轴上移动小矩形,当 rect1true 时,向上移动 14 个点,当它变为 false 时,将矩形向下移动到 -8 个点。我们在这里想要的效果是让这些小矩形上下移动,同时在其光滑部分上淡入淡出,从而产生部分正在旋转的错觉。

现在是 animation 修饰符:它使用 easeInOut 时间曲线,持续时间为半秒,设置为 repeatForever,并将 autoreverses 设置为 true

之后,使用 rotationEffect 修饰符来旋转这个小矩形,使其正好位于我们想要的蜗轮齿轮上的位置。矩形将围绕锚点旋转,我们将锚点设置在矩形的顶部部分。

最后,在 onAppear 修饰符中,我们切换了 rect1,使得动画在视图出现时开始。

现在,运行项目;你可以在预览中看到结果:

图 7.4:动画中的蜗轮齿轮

图 7.4:动画中的蜗轮齿轮

看着图,矩形有点难看清楚,但它确实在那里,就在第三个齿之后的一个微弱的细矩形。如果你想更好地看到矩形,请在 Image 初始化器中拼写错误 wormGear,这将从预览中移除蜗轮齿轮(他们讨厌我们拼写错误!)。当你再次运行项目时,你会看到以下内容:

图 7.5:覆盖蜗轮齿轮的矩形

图 7.5:覆盖蜗轮齿轮的矩形

你应该看到矩形在上下移动,以及淡入淡出。

对于这个矩形的放置,理想情况下,我们希望它在左边,覆盖在第一个牙齿上;然而,这将在我们将其他矩形添加到这个HStack中时发生。记住,HStack从左到右排列一切,但由于我们只有一个矩形在HStack中,所以HStack现在将其放置在中间。

因此,现在让我们在HStack中添加第二个矩形,位于第一个矩形下方:

                    Rectangle()
                        .frame(width: 4, height: 40)
                        .foregroundColor(.black)
                        .cornerRadius(5)
                        .opacity(rect2 ? 0 : 0.3)
                        .offset(x: 7, y: rect2 ? -15 : -8)
                        .animation(Animation.easeInOut                           (duration: 0.5).repeatForever                           (autoreverses: true), value: rect2)
                        .rotationEffect(.degrees(-8))
                        .onAppear(){
                            rect2.toggle()
                        }

这段代码几乎与我们对第一个矩形添加的代码相同。这里的区别是我们将矩形偏移到齿轮图像上的不同位置,并且旋转略有不同,以便它可以与齿轮的下一个闪亮部分对齐。

运行代码,你会看到两个正在动画的矩形。它们还没有完全居中在齿轮的第一个和第二个闪亮的牙齿上,因为我们还有两个矩形要添加,然后HStack将为我们完美地居中每个动画矩形。

在我们继续添加第三个和第四个矩形之前,先看看我们刚刚添加的两个矩形的前三个修饰符。它们在值上都是相同的,所以我们在这里重复了代码,这是我们编程时试图避免的事情。由于这些修饰符中的值没有变化,并且我们将它们用于所有四个矩形,因此,我们可以创建一个自定义修饰符,将这三个修饰符放入其中,从而稍微缩短我们的代码。

要创建一个自定义修饰符,我们需要创建一个符合ViewModifier协议的结构体。这个协议有一个要求,即实现一个名为body content的方法,该方法将接受我们的内容,然后必须返回一个视图。

我们可以在WormGear文件的底部创建一个自定义修饰符。完全移出WormGear结构体,来到这个文件的底部,添加以下结构体:

struct RectModifiers: ViewModifier {
    func body(content: Content) -> some View {
        content
            .frame(width: 4, height: 40)
            .foregroundColor(.black)
            .cornerRadius(5)
    }
}

这是一个自定义修饰符结构体,我称之为RectModifiers;正如你所见,我们在其中实现了body content方法,这是满足ViewModifier协议所需的要求。然后,我添加了我们在代码中重复的三种修饰符(即值没有变化的那些:frameforegroundColorcornerRadius)。

现在,我们只需要回到我们创建的第一个矩形,就在齿轮创建之后,移除这三个修饰符,然后调用我们的自定义修饰符:

.modifier(RectModifiers())

要使用它,我们需要传入我们刚刚创建的自定义结构体的名称,即RectModifiers。这个新的修饰符结构体可以容纳我们想要放入的任意数量的修饰符,因此对于值不发生变化的修饰符来说,在这里使用可能是个好主意;这减少了我们需要编写的代码量,尤其是如果我们有很多视图(例如,如果我们在这个文件中添加 30 或 40 个矩形)。

现在,我们可以继续添加最后两个矩形,以帮助产生这个齿轮正在转动的错觉。下面是代码的样子:

                  Rectangle().modifier(RectModifiers())
                        .opacity(rect3 ? 0 : 0.3)
                        .offset(x: 5, y: rect3 ? -5 : -10)
                        .animation(Animation.easeInOut                           (duration: 0.5).repeatForever                           (autoreverses: true), value: rect3)
                        .rotationEffect(.degrees(-8), anchor:                           .top)
                        .onAppear(){
                            rect3.toggle()
                        }
                    Rectangle().modifier(RectModifiers())
                        .opacity(rect4 ? 0 : 0.3)
                        .offset(x: 4, y: rect4 ? -10 : -10)
                        .animation(Animation.easeInOut                           (duration: 0.5).repeatForever                           (autoreverses: true), value: rect4)
                        .rotationEffect(.degrees(-7), anchor:                           .top)
                        .onAppear(){
                            rect4.toggle()

在我们尝试之前,让我们给这些移动的矩形添加一些阴影,以帮助使它们更加突出。在第一个 ZStack 的闭合花括号末尾添加此代码:

 .shadow(color: .black, radius: 0.4, x: 0.0, y: 1)

通过使用 shadow 修饰符,并传入一个覆盖每个矩形的黑色阴影,我给这个阴影设置了 4 个半径点,阴影将在 y 轴上以 1 的值显示。使用正数值将阴影沿 y 轴移动,而使用负数将阴影沿 y 轴向相反方向移动。尝试调整这些数字,看看它们如何影响阴影的突出程度和位置。

有了这段代码,这个文件现在就完成了。运行项目,看看你的想法:

图 7.6:完成的蜗轮

图 7.6:完成的蜗轮

我们有一个看起来像在转动的蜗轮。注意,每个矩形都已经完全对齐到蜗轮的齿上,从左到右移动。光亮的部分一次又一次地被覆盖和暴露,以一个相当均匀的速度,这实际上就是许多蜗轮的工作速度。

现在我们已经完成了蜗轮的动画,我们将创建一个行军蚁效果,我们可以用它来模拟移动的齿轮链条。我们可以使用 dashPhase 初始化器来完成这个任务。

使用行军蚁效果创建齿轮皮带

现在我们已经动画化了圆形和蜗轮,接下来,我们需要制作一些可以用来缠绕这些齿轮并将它们连接起来的皮带。

我们实际上是在创建一个行军蚁效果。你可能之前已经使用过这个效果,但不知道它叫什么名字——每次你使用鼠标或触控板来勾勒一个视图或围绕对象创建一个边界框以选择它们时,你都在使用行军蚁效果。你可能记得在第六章中,当我们使用 Mac 预览应用中的套索选择工具时,勾勒所选图像部分的短划线就是这种效果的例子。

因此,为了创建我们的齿轮皮带,让我们从这个部分开始添加另一个新文件,我们将称之为 BeltView。然后,像我们通常做的那样,我们首先在 BeltView 结构体中添加使一切工作的变量:

@State var animateBelt: Bool = false
    var beltWidth: CGFloat = 0.0
    var beltHeight: CGFloat = 0.0
    var offsetBeltX: CGFloat = 0.0
    var offsetBeltY: CGFloat = 0.0
    var dashPhaseValue: CGFloat = 45
    var rotateDegrees: Double = 0.0
    var xAxis: CGFloat = 0.0
    var yAxis: CGFloat = 0.0
    var zAxis: CGFloat = 0.0

每个变量都负责一个特定的任务:

  • animateBelt 跟踪皮带动画。

  • beltWidthbeltHeight 设置皮带的宽度和高度。

  • offsetBeltXoffsetBeltY 将皮带定位在屏幕上的特定区域。

  • dashPhaseValue 负责设计皮带——例如,我们希望皮带段之间相隔多远,它们应该有多厚,等等。

  • rotateDegrees 在定位皮带后使用,通过传递一个度数来水平或垂直旋转皮带。

  • xAxisyAxiszAxis分别将皮带定位在xyz轴上。当你开始将这些单独的文件拼接到ContentView中时,你会看到这三个变量发挥作用。

在变量就绪后,我们可以进入结构的body部分,并开始添加创建皮带的代码。创建皮带不需要很多代码。首先,添加一个ZStack来容纳我们的视图:

 ZStack {
        }

接下来,我们想要创建皮带形状。如果你看看大多数由齿轮驱动的皮带或链条,它们的形状类似于胶囊,幸运的是,SwiftUI 为我们提供了一个我们可以使用的胶囊形状。在ZStack内部添加以下代码,包括其修改器:

            Capsule()
                .stroke(Color.black, style: StrokeStyle                   (lineWidth: 7, lineJoin: .round, dash:                   [5, 1.4], dashPhase: animateBelt ?                   dashPhaseValue : 0))
                .frame(width: beltWidth, height: beltHeight)
                .animation(Animation.linear(duration:                   3).repeatForever(autoreverses: false).                  speed(3), value: animateBelt)

这只是大约五行代码,但在这里做了很多工作。首先,我们声明所需的胶囊形状,然后使用stroke修改器。stroke修改器做了很多事情,并且也负责设计皮带:它的第一个参数将为皮带赋予颜色(我们选择了黑色),第二个参数是strokeStyle参数,其中我们传递一个StrokeStyle结构体。

这个StrokeStyle结构体有一些自己的参数,有助于美化皮带:

  • 第一个参数是lineWidth。这个参数相当直观;它只是意味着我们想要将皮带设置多宽,我们将其设置为7点。

  • 此外,还有一个lineJoin参数。这个值决定了皮带各段如何连接在一起。我们可以使用三种选项,roundbevelmiter;我认为使用round选项的线段看起来最好,但请随意尝试所有这些值和数字,以获得最适合你的外观。

  • 下一个参数是dash,它负责创建皮带的段长度以及这些段之间的间隙。此参数的第一个值将决定段长度(较大的数字使段更大,而较小的数字使段更小);我使用5点作为此值。第二个值决定了段之间的间隙大小(较大的数字创建更大的间隙,而较小的数字创建更小的间隙);对于此值,1.4点的值创建了一个看起来非常好的间隙。

strokeStyle结构体之后,下一个修改器是frame,它设置了整个皮带的宽度和高度。然后我们添加了一个animation修改器,具有linear动画和三秒钟完成一次旋转的持续时间,并将autoreverses设置为false(因为我们只想让皮带单向转动)。

现在我们需要添加onAppear修改器,以便在应用加载时开始动画。为此,在现有代码下方添加以下代码:

      .onAppear {
                    animateBelt.toggle()
                }

此代码将animateBelt属性切换为true,从而启动皮带动画。

我们还需要添加最后一段代码来完成皮带样式的设置,那就是将皮带旋转到适合齿轮的正确角度。在 ZStack 的闭合括号之后,添加以下代码:

        .shadow(color: .black, radius: 10, x: 1, y: 0)
        .rotationEffect(.degrees(rotateDegrees), anchor:           .center)
        .offset(x: offsetBeltX, y: offsetBeltY)

这段代码将对 ZStack 中的所有内容起作用,因为它被放置在其闭合括号之后。在这里,我添加了一个 shadow 修饰符,将颜色设置为黑色,半径设置为 10 以使皮带更加突出,并将阴影放置在 x 轴上。你可以尝试调整阴影的颜色和这些数字:通过增加半径,你可以使阴影更大,通过增加 xy 参数的数值,你可以将阴影向上、向下、向左或向右移动。记住,你可以使用负数来将阴影移动到相反的方向。

我们将要放置在整个 ZStack 上的下一个修饰符是 rotationEffect。这将使完成的皮带旋转到我们指定的角度;当我们稍后在 ContentView 中调用这些各种方法时,我们将传入不同的值来定位皮带并调整其大小,使其正好符合我们的需求。

最后,我们添加了 offset 修饰符,它允许我们使用 xy 坐标将皮带放置在屏幕上的任何位置。

如果你尝试测试我们所做的,你预览中现在不会看到任何东西,因为我们刚刚向 BeltView 结构体中添加了一堆不同的变量。然而,我们并没有在 Previews 结构体中使用这些变量。为了解决这个问题,让我们更新 Previews 结构体到以下内容:

struct BeltView_Previews: PreviewProvider {
    static var previews: some View {
        BeltView(animateBelt: true, beltWidth: 380, beltHeight:           48, offsetBeltX: 0, offsetBeltY: 0, rotateDegrees:           90)
            .previewLayout(.fixed(width: 100, height: 400))
    }
}

当你现在运行 Previews 时,你会看到皮带在动作:

图 7.7:带有行军蚁效果的完成皮带

图 7.7:带有行军蚁效果的完成皮带

它是顺时针旋转的,有很好的样式和间距,以适应我们稍后的齿轮,阴影使其真正从屏幕上凸出来。

注意

如果你注意到皮带没有平滑地动画,这意味着它在旋转,但每隔几秒钟会稍微暂停一下,你可以尝试调整 dashPhase 值,该值用于指定虚线或点划线的起始点。我将其设置为初始值 45,这为我消除了暂停,但如果它没有为你项目消除暂停,只需将此值增加或减少 1,检查动画,然后再次通过 1 调整值,直到找到最佳点。

那就是另一个文件,我们已经完成了(抱歉,我忍不住了!)。让我们继续到下一部分,我们将添加一个齿轮轴。这个对象将转动皮带,而皮带反过来会转动风扇。

动画齿轮轴图像

继续到下一个组件,我们需要一个齿轮轴。齿轮轴是一个圆柱形杆,两端有圆形齿轮,用于连接其他齿轮或皮带,最终产生某种形式的输出或工作。例如,在你汽车中的发动机内,有一个齿轮轴,由于汽油燃烧而转动。该过程产生的输出或工作使汽车前进。我们的齿轮轴不会移动汽车,而是会转动风扇。创建这个组件后,就像蜗轮齿轮一样,我们将通过使用动画矩形来动画化齿轮轴。同样,这是因为我们使用的图像不是圆形的,所以我们不能像其他齿轮图像一样在z轴上旋转它。

让我们从创建一个新的 SwiftUI 视图文件开始,我们将称之为GearShaftView。接下来,让我们添加这个文件所需的变量;我们只需要一个变量,那就是用于跟踪动画的变量:

 @State var animateRect: Bool = false

在变量之后,我们只需要添加齿轮轴图像的代码并对其动画化。在body属性中添加以下代码:

var body: some View 
        ZStack {
            ZStack {
                Image("shaft").resizable().frame(width: 160,                   height: 40)
                Rectangle().frame(width: 140, height: 8)
                    .foregroundColor(.black)
                    .cornerRadius(5)
                    .opacity(animateRect ? 0 : 0.5)
                    .animation(Animation.easeInOut(duration:                       0.5).repeatForever(autoreverses: true),                       value: animateRect)
                    .onAppear() {
                        animateRect.toggle()
                    }.offset(x: 0, y: -7)
            }
        }
    }

我们首先将齿轮轴图像引入场景,并设置其宽度和高度尺寸。之后,我们添加一个矩形,它将作为动画阴影在轴上上下移动。阴影的颜色是黑色,并添加了一点点圆角半径。不透明度将从 0(不可见)动画到 0.5(50%可见)。这将产生一个漂亮的阴影矩形,其出现和消失的节奏与齿轮和轴的旋转节奏相同。

在下一行代码中,添加了动画,完成一次旋转需要半秒钟。它将autoreverses设置为true,因为我们如果将autoreverses设置为false,那么动画看起来会太突然(它需要autoreverses以便将矩形滑回原位)。

然后,我们在onAppear方法中开始动画,并使用offset修改器将矩形偏移,使其整齐地放置在我们想要的位置,即轴图像上方,x设置为0y设置为-7

我们即将将这些文件全部组合在一起,但接下来,我们将动画化风扇图像。

风扇图像的动画

实际上还有两个组件需要创建,即风扇和电机。然而,当我们开始在ContentView内部拼接所有文件时,我们将添加电机。因此,让我们现在专注于创建风扇。

和往常一样,创建一个新的文件,选择FanView。在这个文件中,我们只需要两个变量——一个用于动画的状态,另一个用于存储风扇的旋转角度;它们如下所示:

       @State private var rotateFan = false
    var degrees: Double = 360 * 4

这个文件相当短,所以只需添加以下代码来完成它:

var body: some View {
        ZStack {
        Image("fan").resizable().aspectRatio(contentMode:           .fit).frame(width: 200)
            .rotationEffect(.degrees(rotateFan ? degrees : 0),               anchor: .center)
            .animation(Animation.linear(duration: 4)              .repeatForever(autoreverses: false),               value: rotateFan)
        }.onAppear() {
            rotateFan.toggle()
        }.shadow(color: .black, radius: 15)
    }

让我们回顾一下这段代码。在 ZStack 中,我们添加了风扇的图片并调整了其大小。然后,我们使用 rotationEffect 修饰符来使其旋转。我们希望风扇围绕其中心旋转,因此我们将锚点设置为 center,然后添加一个持续时间为 4 的动画,这意味着它将在 4 秒内旋转 4 圈。之后,将动画设置为 repeatForever 并将 autoreverses 设置为 false(反向,因为我们希望风扇只按一个方向旋转)。

然后,让我们使用 onAppear 修饰符开始动画,并在风扇周围添加一个半径为 15 点的阴影。

现在风扇就完成了。接下来,让我们转到 ContentView 来创建最终的组件——电机,并开始将所有文件整合到一个视图中。

在 ContentView 中整合所有内容

好了——我们已经完成了许多工作,包括为单齿轮和双齿轮、蜗轮、换挡器和风扇创建了文件。现在,让我们创建电机的最终文件,并将所有这些文件组织到 ContentView 中。

我们将使用一个名为 Pragma Marks 的 Swift 功能;这是一个特殊的语法,它使用非常细的线来标记和划分代码块,并使这些标签出现在一个下拉菜单中,以便于搜索和导航。这对于拥有数百或数千行代码的大型文件非常有帮助。

我们还将使用一个名为 ContentView 的 SwiftUI 功能,通过将多个对象(如视图、场景或甚至命令)组合成一个单一单元来使用。我们将根据代码是否在 xyz 轴上动画化,将大部分代码组织到不同的组中。

然后,我们将使用 ZIndex 修饰符。这个修饰符与重叠的视图一起工作,因此可以用来强制一个视图位于其他视图的前面或后面。这很重要,因为有时我们需要一个视图在场景中更加突出,而另一个视图则被隐藏在场景后面,只有部分可见。你很快就会看到它是如何工作的。

为了帮助你完成这个项目,我还为所有视图添加了标签,以帮助你识别它们在屏幕上的位置以及它们的朝向:

图 7.8:带有标签的我们的项目视图

图 7.8:带有标签的我们的项目视图

G 标注指的是齿轮,B 标注指的是皮带。我根据它们在代码中出现的顺序为每个齿轮和皮带编号。我们将按编号顺序编写代码——所以,G1,然后 G2,然后 G3,依此类推。图示显示了所有部件的放置位置。

和往常一样,当我们开始项目时,ContentView 文件已经为我们创建好了,所以我们不需要创建任何新的文件。在 ContentView 中,我们首先要做的是添加电机。

添加电机

要添加电机,进入 ContentViewbody 属性,然后添加一个主要的 ZStack 来包含所有内容。然后,在那个内部放置另一个 ZStack

ZStack {
     ZStack {
            }
       }

在第二个 ZStack 内部,让我们添加第一个组织结构,即 Pragma 标记。要创建一个 Pragma 标记,语法需要两个正斜杠和用大写字母写的 MARK,后面跟着一个冒号。这将创建一条穿过文件的细线。你可以在那之后写任何你想要的内容作为标题。

因此,要为电机创建一个 Pragma 标记,输入以下代码:

 //MARK: - MOTOR

现在,注意 Pragma 标记在编辑器中从文件一端到另一端创建的非常细的线:

图 7.9:我们的第一个 Pragma 标记

图 7.9:我们的第一个 Pragma 标记

Pragma 标记也同时在幕后做一些非常有用的操作。如果你查看 Xcode 菜单栏的左上角,那里显示所有标签的地方,你会看到一个 MOTOR 标签:

图 7.10:Pragma 标记标签

图 7.10:Pragma 标记标签

如果你点击标签,将弹出一个下拉菜单,包含你创建的所有 Pragma 标记;点击任何一个都会立即带你到文件该部分的代码。当你在一个文件中有成百上千个不同的代码块时,你会看到这个功能的有用性,这样可以避免在冗长的文件中滚动。

现在,让我们添加创建场景中电机的代码:

Group {
       Image("motor").resizable().aspectRatio(contentMode:          .fit).frame(width: 140, height: 120)
           .offset(x: -120, y: 90)
                }

这是我们第一次使用 Group 语法。查看组内的代码,它包含了我们一直在做的事情:使用 Assets 目录中的图片创建电机,调整大小,设置宽高比以便电机按我们的要求适应屏幕,设置框架大小,并在 x 和 y 轴上偏移。

如果我们运行代码,到目前为止预览中应该有这个样子:

图 7.11:电机

图 7.11:电机

那个组中只有一个物体,即电机,但这完全没问题——随着我们创建更多的组,我们将添加更多物体。现在,让我们添加一些齿轮到场景中。

沿着 x、y 和 z 轴添加齿轮

在本节中,我们将使用 GearView 结构体来添加齿轮。记住,我们已经在 GearView 文件中设计和动画化了齿轮,所以我们在这里在 ContentView 中调用 GearView 结构体并传递一些值即可。

这是我们将在 x、y 和 z 轴上旋转齿轮和皮带的地方,所以为了回顾每个轴在屏幕上的操作(如 图 7.1 中所示):

  • y 轴从上到下和从下到上运行。

  • x 轴从左到右和从右到左运行。

  • z 轴从后向前或从前向后移动。用手拿着物体并靠近眼睛是沿着 z 轴移动该物体的一个例子。

通过理解这些,让我们开始添加组件。在电机组的闭合花括号后立即放置以下代码:

      //MARK: - GEARS ANIMATING ON THE Z AXIS
                Group {
                    ///Gear 1
                    GearView(gearImage: "doubleGear",                       gearWidth: 40, gearDegrees: 360,                       offsetGearX: -124, offsetGearY: 102,                       duration: 5)
                    ///Gear 2
                    GearView(gearImage: "doubleGear",                       gearWidth: 100, gearDegrees: 360,                       offsetGearX: -124, offsetGearY: -280,                       duration: 7)
                    ///Gear 3
                    GearView(gearImage: "doubleGear",                       gearWidth: 100, gearDegrees: 360,                       offsetGearX: 124, offsetGearY: -280,                       duration: 7)
                    ///Gear 4
                    GearView(gearImage: "doubleGear",                       gearWidth: 100, gearDegrees: 360,                       offsetGearX: 124, offsetGearY: -70,                       duration: 7)
                    ///Gear 5
                    GearView(gearImage: "doubleGear",                       gearWidth: 80, gearDegrees: -360,                       offsetGearX: 49, offsetGearY: -113,                       duration: 5)
                    ///Gear 6
                    GearView(gearImage: "doubleGear",                       gearWidth: 100, gearDegrees: 360,                       offsetGearX: -6, offsetGearY: -80,                       duration: 7)
                }

我将这个组命名为 GEARS ANIMATING ON THE Z AXIS。在这里,我调用了 GearView 结构六次,创建了六个在 z- 轴上旋转的齿轮。让我们只看看 Gear 1,因为它们非常相似,只是值不同。

因此,在 Gear 1 中,我将齿轮的宽度设置为 40 点,这使得它成为一个小齿轮,并使用 offsetGearXoffsetGearY 参数将其直接定位在电机的前面。传递给 gearDegrees 参数的度数是 360;这是一个圆的一周,因为我们希望这些齿轮完成一次完整旋转。

由于这些是 2D 图像而不是齿轮的 3D 图像,深度实际上是不可感知的,齿轮将平躺并顺时针动画化。duration 参数控制齿轮完成一次完整旋转所需的时间;我使用 7 的值用于较大的齿轮,而较小的齿轮使用 5 的值。小齿轮和较大齿轮是通过 gearWidth 参数制作的。

观察到 gearDegrees 参数,我使用的值都设置为 360(一个正数),以使它们顺时针旋转。嗯,这除了 Gear 5 以外,它被设置为 -360(一个负数),因为那个齿轮将逆时针旋转。

这就是我们刚才编写的代码应该看起来像的:

图 7.12:在 z 轴上旋转的齿轮

图 7.12:在 z 轴上旋转的齿轮

如果你在这个模拟器中运行它,所有这些齿轮现在都会转动,因为我们已经在 GearView 文件中设置了动画。

让我们继续并添加另一组齿轮,这次它们将在 y- 轴上动画化。在上一组代码的闭合花括号下添加以下代码:

                //MARK: - GEARS ANIMATING ON THE Y AXIS
                Group {
                    ///Gear 7
                    GearView(gearImage: "singleGear",                       gearWidth: 100, gearDegrees: -360,                       offsetGearX: -62, offsetGearY: -85,                       rotateDegrees: 76, duration: 7, xAxis: 0,                       yAxis: 1, zAxis: 0)
                    ///Gear 8
                    GearView(gearImage: "singleGear",                       gearWidth: 25, gearDegrees: -360,                       offsetGearX: -59, offsetGearY: 19,                       rotateDegrees: 76, duration: 7, xAxis: 0,                       yAxis: 1, zAxis: 0)
                    ///Gear 10
                    GearView(gearImage: "singleGear",                       gearWidth: 100, gearDegrees: -360,                       offsetGearX: 160, offsetGearY: 94,                       rotateDegrees: 76, duration: 7, xAxis: 0,                       yAxis: 1, zAxis: 0)
                    ///Gear 11
                    GearView(gearImage: "singleGear",                       gearWidth: 25, gearDegrees: -360,                       offsetGearX: 163, offsetGearY: 252,                       rotateDegrees: 76, duration: 7, xAxis: 0,                       yAxis: 1, zAxis: 0)
                }

再次,我们从这个组的代码开始,添加我们的组织性 Pragma 标记,称为 GEARS ANIMATING ON THE Y AXIS。在这里,我们调用了 GearView 结构四次,创建了四个将在 y- 轴上旋转的齿轮。

Gear 7 为例,它的宽度为 100 点,gearDegrees 被设置为 -360(这意味着齿轮逆时针旋转)。接下来,代码使用 offsetGearXoffsetGearY 参数重新定位齿轮。通过使用 rotateDegrees 参数并传递 76 的值,我们可以在 y- 轴上旋转这个齿轮。

其他三个齿轮几乎完全相同,只是它们的大小和偏移位置不同,但它们都将旋转在 y- 轴上。

添加这一组代码后,你的预览应该看起来像这样:

图 7.13:在 y 轴上旋转的齿轮

图 7.13:在 y 轴上旋转的齿轮

现在,当你运行模拟器时,所有这些齿轮都会在其设定的轴上旋转。

让我们继续并添加另一个组,它将使一个齿轮在x轴上动画化。在上一组的闭合括号之后添加以下代码:

  //MARK: - GEAR ANIMATING ON THE X AXIS
   Group {
                    ///Gear 9
                    GearView(gearImage: "singleGear",                       gearWidth: 175, gearDegrees: 360,                       offsetGearX: 60, offsetGearY: 39,                       rotateDegrees: 84, duration: 7,  xAxis:                       1, yAxis: 0, zAxis: 0)
                }

GEAR ANIMATING ON THE X AXIS组这里只有一个齿轮,这是迄今为止最大的齿轮,宽度为175点。这个齿轮将与两个其他齿轮啮合——场景中它右侧的齿轮和蜗轮齿轮。

这与我们已经放置的其他齿轮的代码类似,我们使用GearView初始化器来创建它,并使用相同的参数在场景中调整大小和定位,但这里的区别在于我们使用了xAxis参数,并传入了一个值为1xAxis参数将在这个齿轮上沿x轴旋转,这与y轴或z轴的旋转角度完全不同。旋转角度为84度。

在我们运行此代码并检查之前,让我们添加蜗轮齿轮,看看一切是如何配合在一起的。

添加蜗轮齿轮

要将蜗轮齿轮添加到我们的ContentView中,请添加以下代码:

 //MARK: - WORM GEAR
                Group {
                    WormGearView().offset(x: 60, y: 30).                      zIndex(-1)
                }

这个组合被标记为WORM GEAR,并使用offset修饰符进行定位。不过这里有一个新东西,那就是zIndex修饰符。zIndex修饰符将视图放置在其他视图之前或之后,这允许我们从前面到后面或相反方向定位我们的视图。

我已经讨论了z轴,它与深度和物体向我们靠近或远离有关。我们为什么要移动一个视图靠近或远离我们的视角呢?好吧,让我们看看如果我们不在代码中使用zIndex修饰符会发生什么例子:

图 7.14:不使用 zIndex 添加蜗轮齿轮

图 7.14:不使用 zIndex 添加蜗轮齿轮

从图中可以看出,蜗轮齿轮现在已移动到前面,这不是我想要的位置。我想让蜗轮齿轮从Gear 9后面啮合。

让我们现在将zIndex修饰符重新添加到我们的代码中,并查看结果:

图 7.15:使用 zIndex 添加蜗轮齿轮

图 7.15:使用 zIndex 添加蜗轮齿轮

SwiftUI 中的所有视图都有一个默认的zIndex值为0,所以当我传入一个负数时,蜗轮齿轮被放置在Gear 9后面。正如你所看到的,这在尝试组织你的视图,使其更接近或远离你时,可以是一个重要的修饰符。

有了这些,我们已经完成了所有的齿轮,所以现在我们可以继续添加皮带到场景中。

添加皮带

继续构建我们的场景,让我们添加皮带。添加以下包含三个在z轴上移动的皮带的组合:

//MARK: - BELTS ON THE Z AXIS
                Group {
                    ///Belt 1
                    BeltView(animateBelt: true, beltWidth:                       425, beltHeight: 48, offsetBeltX: -124,                       offsetBeltY: -90, rotateDegrees: 90)
                    ///Belt 2
                    BeltView(animateBelt: true, beltWidth:                       352, beltHeight: 100, offsetBeltX: 0,                       offsetBeltY: -280, rotateDegrees: 0)
                    ///Belt 3
                    BeltView(animateBelt: true, beltWidth:                       258, beltHeight: 48, offsetBeltX: 124,                       offsetBeltY: -175, rotateDegrees: 90)
                }

在这里,我们调用了BeltView结构体,将animateBelt属性设置为true,然后为每条皮带设置了适当的宽度和高度,以便它们可以使用beltWidthbeltHeight参数连接到相应的齿轮。之后,我们使用offsetBelt参数将皮带放置在它们需要的位置。最后,我们将皮带旋转 90 度,使皮带垂直,或者旋转 0 度,使另一条皮带水平。以下是它们与它们啮合的齿轮的相对位置:

图 7.16:z 轴上的皮带

图 7.16:z 轴上的皮带

现在,让我们来看看y-轴上的旋转皮带。和之前一样,在上一组的闭合括号之后添加以下代码:

  //MARK: - BELTS ON THE Y AXIS
                Group {
                    ///Belt 4
                    BeltView( beltWidth: 32, beltHeight: 125)
                        .rotation3DEffect(.degrees(75), axis:                           (x: 0, y: 1, z: 0))
                        .offset(x: -60, y: -33)
                    ///Belt 5
                    BeltView(beltWidth: 28, beltHeight: 180,                       offsetBeltY: -10)
                        .rotation3DEffect(.degrees(75), axis:                           (x: 0, y: 1, z: 0))
                        .offset(x: 162, y: 185)
                }

再次调用BeltView结构体,我们将这两条皮带设置为与它们将要啮合的齿轮相匹配的宽度和高度。然后,我们使用了rotation3DEffect修改器,并在y-轴上旋转了这些皮带。当我们将1的值传递到y参数时,它们将旋转 75 度。和其他皮带一样,我们使用offset修改器将它们偏移,使它们与相应的齿轮对齐。

现在,你的预览应该看起来像这样:

图 7.17:y 轴上的皮带

图 7.17:y 轴上的皮带

如果你在这个模拟器中运行项目,所有这些皮带和齿轮都将完全动画化。这让我们只剩下两个小组要完成——齿轮轴和风扇,以及背景。

添加齿轮轴

要将轴引入场景,请添加以下代码:

//MARK: - GEAR SHAFTS
                Group{
                    ///Shaft 1
                    GearShaftView().offset(x: 5, y: 28).                      zIndex(-1)
                    ///Shaft 2
                    GearShaftView().offset(x: 95, y: 260).                      zIndex(-1)
                }

这是我们的GEAR SHAFT小组。在这里,我调用了两次GearShaftView结构体,以创建两个齿轮轴。然后,我们只需简单地将它们偏移到正确的位置,并对它们调用zIndex,传入一个负值。这将使它们位于其他视图的后面,从而产生齿轮轴实际上是蜗轮齿轮一部分的错觉。以下是之前代码产生的结果:

图 7.18:齿轮轴

图 7.18:齿轮轴

蜗轮齿轮和齿轮轴是两个我们无法使用传统方法进行动画化的对象,因为那样看起来不好,而且也不会准确反映它们在物理世界中的转动方式。因此,我们创建了我们的阴影框,并将它们战略性地放置在这两个轴的上方。当你在这个模拟器中运行时,你会注意到这些矩形似乎使蜗轮齿轮和两个轴转动。

记住,这些只是静止在屏幕上什么也不做的图像,但多亏了一点小技巧,它们实际上看起来像是在转动!

现在,让我们添加最终的组——风扇。

添加风扇

如果所有这些齿轮和皮带都能产生某种输出那就太好了,所以在本项目,这个输出将是旋转一个风扇。现在,让我们在现有组下方添加最终的代码组合:

  //MARK: - FAN
                Group {
                    ///Fan
                    FanView().offset(x: 0, y: 250).frame(width: 140, height: 140)
                }

我们所做的一切都称为FanView结构,将其偏移到场景底部,并为其设置宽度(因为它圆形,所以我们不需要设置高度)。

接近完成,只剩最后一个任务:添加背景。

添加背景

我有一个漂亮的金色背景,与所有物体搭配起来会非常好看,所以让我们从主ZStack中退出,在其闭合括号之后,添加以下代码:

 //MARK: - BACKGROUND
.background(Image("goldBackground").resizable().aspectRatio(contentMode: .fill)
            .frame(width: 400, height: 1000))

这将金色背景设置为400 x 1000的框架宽度和高度,完美地放置在 iPhone 上。

最后,在模拟器中运行并看看你的想法:

图 7.19:完成的项目

图 7.19:完成的项目

随意调整所有参数和设置,改变皮带或物体的颜色,改变旋转角度等等。这将帮助你真正理解这些参数和函数都做了什么,以及它们如何协同工作!

此外,请注意,我们在整个项目中继续使用Pragma Marks。让我们看看它们有多方便;所以,回到菜单栏并点击最后一个标签(这个标签没有名字,但你可以在菜单栏的最后一个标签中总是找到Pragma Marks):

图 7.20:查看项目的

图 7.20:查看项目的Pragma Marks

每个Pragma Marks都基于我们给出的标题指示不同的代码区域,这使得无论文件有多大,都能瞬间跳转到它。

摘要

总结,通过创建这个项目,我们取得了相当大的成就。就像之前一样,你看到了如何创建单独的文件——在这个案例中,齿轮和链条——如何制作蚂蚁行军效果,如何将一切组合到ContentView中,以及如何使用Pragma Marks和组来整洁地组织代码。你还学习了如何沿所有三个轴(xyz)动画化对象,以及如何使用zIndex更动态地放置视图,从后往前,反之亦然。

在下一个项目中,我们将查看如何使花朵及其花瓣看起来像在呼吸,并在背景中创建一些雪花。

第八章:动画一束花

欢迎来到下一个项目。在这里,我们将创建一束花,动画花瓣使其开合,然后添加烟雾/蒸汽效果,使其看起来像花在呼吸,使用blur修改器。

在花朵后面,我们将放置一个冬季背景,并使用CAEmitter类使其下雪。CAEmitter类是一个用于动画的 UIKit 类,但为了能够访问它,我们需要使用一个名为UIViewRepresentable的 SwiftUI 桥接协议。UIViewRepresentable协议将使我们能够桥接两个框架,UIKit 和 SwiftUI。

与此同时,我们将包括两个标签——吸气呼气——这样你就可以与花朵一起呼吸,类似于冥想应用。

因此,在本章中,我们将涵盖以下主题:

  • 添加变量和冬季背景

  • 动画文本标签

  • 使用blur修改器创建蒸汽效果

  • 在弧形中动画花瓣

  • 添加花束和动画呼吸

  • 在场景中创建下雪效果

技术要求

您可以从 GitHub 上的Chapter 8文件夹下载资源和完善的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

添加变量和冬季背景

让我们开始创建一个新的 SwiftUI 项目——我将其命名为Breathing Flower

接下来,请继续下载本项目的资源。这些图像包括bouquetpetalsmokesnowwinterNight。下载后,将这些图像拖放到资源目录中。

我们现在已将所需的图片加载到项目中。那么,让我们从ContentView开始,添加使这朵花栩栩如生的变量:

import SwiftUI
struct ContentView: View {
    @State private var petal = false
    @State private var breatheInLabel = true
    @State private var breatheOutLabel = false
    @State private var offsetBreath = false
    @State private var diffuseBreath = false
    @State private var breatheTheBouquet = false

到现在为止,你应该已经熟悉了创建动画变量的过程。在这里,我们创建了许多变量,包括以下内容:

  • Petal:用于追踪花瓣的运动

  • breatheInLabelbreatheOutLabel:用于追踪吸气呼气标签

  • offsetBreath:将呼吸从花内部移动到花外部

  • diffuseBreath:跟踪烟雾从静态图像到动画的转换

  • breatheTheBouquet:用于跟踪花束动画

在我们将这些变量投入使用之前,让我们进入body属性,并向场景添加一个冬季图像。首先,创建一个ZStack来容纳我们将要添加的所有视图:

var body: some View {
    ZStack {
        //MARK: - ADD A WINTER BACKGROUND - AND THE SNOW
        Image("winterNight").resizable()
          .aspectRatio(contentMode: .fill)
                            .frame(width: 400, height: 900)
       }
      }

这是我们现在熟悉的代码。在这里,我们使用Image初始化器,并传入名为winterNight的背景图像,该图像位于资源目录中。然后,我们调整图像大小,并使用fillaspectRatio选项使其占据整个屏幕,最后使用frame修改器为其添加一些尺寸。这就是场景的样子:

图 8.1:冬夜背景

图 8.1:冬季夜晚背景

现在我们已经为场景添加了背景,我们的下一个目标是添加一些标签:Breathe InBreathe Out。我们想要实现的是让这两个标签在花瓣打开和关闭的同时生长和收缩,因此这些标签将具有与项目中花瓣相同的持续时间和延迟,完美同步。

动画文本标签

现在,让我们添加两个标签,作为用户观察呼吸的指南。仍然在 ZStack 中工作,直接在上一行代码的下方添加另一个 ZStack,并填充以下内容:

//MARK: - ANIMATE TEXT LABELS SO THEY GROW AND SHRINK
  //a ZStack so we can offset the entire scene vertically
  ZStack {
     Group {
        Text("Breathe In")
             .font(Font.custom("papyrus", size: 35))
             .foregroundColor(Color(UIColor.green))
             .opacity(breatheInLabel ? 0 : 1)
             .scaleEffect(breatheInLabel ? 0 : 1)
             .offset(y: -160)
             .animation(Animation.easeInOut(duration: 
               2).delay(2).repeatForever(autoreverses: 
               true), value: breatheInLabel)
                 Text("Breathe Out")
             .font(Font.custom("papyrus", size: 35))
             .foregroundColor(Color(UIColor.orange))
             .opacity(breatheOutLabel ? 0 : 1)
             .scaleEffect(breatheOutLabel ? 0 : 1)
             .offset(y: -160)
             .animation(Animation.easeInOut(duration: 
               2).delay(2).repeatForever(autoreverses: 
               true),value: breatheOutLabel)
            }
         }

让我们稍微分析一下这段代码,看看我们在做什么。首先,我们添加了一个第二个 ZStack,然后是一个组,以帮助保持代码的整洁。

接下来,我们使用 Text 初始化器并输入我们希望在屏幕上显示的任何文本。在我们的例子中,我们输入了 Breathe InBreathe Out,就这样,我们在屏幕上有了文本。然后,使用 font 修饰符,你可以将字体更改为你选择的任何一种。Xcode 内置了许多字体,我正在使用一个名为 papyrus 的字体。

注意

如果你想知道可用于 iOS 的字体名称,你可以访问以下网站:developer.apple.com/fonts/system-fonts/

除了选择特定的字体类型外,我们还设置了字体 size 参数为 35,并使用 foregroundColor 修饰符将 Breathe In 标签设置为绿色,将 Breathe Out 标签设置为橙色。

接下来,我们通过使用 breatheInbreatheOut 变量来动画化文本标签的不透明度。当这些变量为 true 时,它们的不透明度将被设置为 0,这使得标签文本不可见。但当 breatheInbreatheOut 变量为 false 时,文本将被设置为 1,它们将再次变得可见。

之后,我们通过使用 scaleEffect 修饰符设置两个文本标签的大小。我们使用三元运算符,检查 breatheInbreatheOut 变量是否为 true;如果是,则将标签缩放到 0;否则,当 breatheInbreatheOut 变量为 true 时,我们将文本标签恢复到全尺寸,即 35 点。

下一行代码使用 offset 修饰符在屏幕上定位标签的 y 轴位置。记住,y 轴位置视图是垂直的。

最后,我们添加了 animation 修饰符。动画将在 breatheInbreatheOut 变量变为 true 时开始,就像通常那样。动画将持续 2 秒,这意味着它需要 2 秒才能完成,然后会有 2 秒的延迟再次开始。

这样就在第一个 ZStack 的闭合括号之后完成了 onAppear 修饰符的动画:

   .onAppear {
                breatheInLabel.toggle()
                breatheOutLabel.toggle()
            }

正如我们所见,onAppear会在视图首次出现时运行其体内的代码——也就是说,当用户点击应用以打开它时。我们想要运行的代码是breatheInbreatheOut变量;我们想要将它们切换到其相反的布尔值以启动动画。

当你在预览或模拟器中运行此代码时,你会看到两个标签,每个标签都有其自己的颜色,可以放大和缩小,同时,还可以淡入淡出:

图 8.2:添加了“吸气”和“呼气”标签

图 8.2:添加了“吸气”和“呼气”标签

这样就完成了标签。现在,让我们集中精力制作一个看起来像蒸汽的图像来表示呼吸。

使用模糊修饰符创建蒸汽效果

在这个项目部分,我们将使用blur修饰符,它将使用我们指定的半径值对图像应用 Gaussian 模糊。如果你不熟悉高斯模糊是什么,这是一种在图像编辑软件(如 Photoshop)中广泛使用的技巧,它通过减少图像的噪声和细节来创建平滑的模糊视觉效果。

我们将使用一个名为breath的烟雾图像(它在资产目录中),并对其应用blur修饰符,这将创建蒸汽效果并使花朵看起来像在呼吸。

实现此效果所需的代码非常少。从我们之前创建的组中出来,让我们创建一个新的组并添加以下代码:

//MARK: - TAKE AN IMAGE AND CONVERT IT TO VAPOR (BREATH)
  USING THE BLUR MODIFIER
      Group {
          Image("breath").resizable().frame(width: 35,
            height: 125)
              .offset(y: offsetBreath ? 90 : 0)
              .animation(Animation.easeInOut(duration:
                2).delay(2).repeatForever(autoreverses:
                true),value: offsetBreath)
              .blur(radius: diffuseBreath ? 1 : 60)
              .offset(x: 0, y: diffuseBreath ? -50 : -100)
              .animation(Animation.easeInOut(duration:
                2).delay(2).repeatForever(autoreverses:
                true), value: showBreath)
      }.shadow(radius: showBreath ? 20 : 0)

在组中,我们使用Image初始化器并传入我们想要使用的图像,breath。然后,我们给它一些尺寸,宽度为35,高度为125

之后,代码在y轴上垂直偏移图像。然后,当offsetBreath变量变为true时,图像向上移动90点,因此它从花朵中出来,而当offsetBreathfalse时,图像会向下移动并恢复到0

接下来,我们将动画添加到图像和offset修饰符中(是的,我们也可以对offset修饰符进行动画处理!)。我们使用与文本标签相同的durationdelay值(分别为 2 秒),以便标签和蒸汽动画同步进行。

下一行是blur修饰符——这就是将图像变成一团蒸汽的魔法所在。此修饰符有一个名为radius的参数,它接受任何整数;数字越小,对图像应用的 Gaussian 模糊越少,而数字越大,图像就越模糊。三元运算符负责将radius值设置为160,因此,根据diffuseBreath变量是true还是false,我们可以控制图像上的 Gaussian 模糊量。当diffuseBreath变为true时,图像只会被模糊1点,但当diffuseBreath变为false时,代码将向图像添加60点的 Gaussian 模糊。

让我们看看下一行代码——这是offset修改器,它负责定位模糊。我们希望将烟雾偏移,使其从花内部移动到花外部。这是通过检查diffuseBreath变量来实现的;当它是true时,蒸汽向上移动50点,当它是false时,蒸汽向下移动100点。

之后,animation修改器将通过所有高斯模糊值进行插值,从160。在这个插值(或者如果你愿意,循环),这些值会非常快速地作用于图像,以至于非移动的静止烟雾图像将变成一个实际移动并散开的烟雾动画,就像真实的烟雾一样。

注意

你可能也注意到了我们在这里使用了两个animation修改器。这样做的原因是第一个修改器紧跟在offset修改器之后,因此它被用来动画化图像的上下偏移;任何添加在animation修改器之后的代码将不会进行动画化,这就是为什么需要第二个animation修改器。第二个修改器用于动画化blur修改器以产生烟雾,并动画化移动烟雾的偏移。所以记住,animation修改器作用于它们上面的视图,但如果我们需要添加更多的它们,我们只需按需添加以动画化任何后续视图,就像我们在这里所做的那样。

最后,我们添加了shadow修改器,它将在移动的呼吸离开花时在其周围添加一个微妙的20点阴影。

现在,为了看到烟雾的转换效果,将offsetBreathdiffuseBreath变量添加到onAppear修改器中,如下所示:

   .onAppear {
                breatheInLabel.toggle()
                breatheOutLabel.toggle()
                offsetBreath.toggle()
                diffuseBreath.toggle()
            }

这是我们能看到的内容:

图 8.3:动画中的烟雾

图 8.3:动画中的烟雾

当动画开始时,烟雾的图像仅被1点模糊,但随着offset修改器将图像向上移动,模糊程度逐渐增加到60点。在60点时,原本静止的图像已经变成了烟雾,模仿了呼吸的效果。

作为补充,稍后值得关注的一个有趣的事情是,如果你想在任何时候看到烟雾图像变成移动蒸汽的过程,那么在shadow修改器下面添加这一行代码:

 .zIndex(1)

这将移动烟雾图像到场景的前面,你会看到它被转换成移动的蒸汽。

在添加了蒸汽效果之后,我们可以继续下一步,即将花束添加到图像中。

以弧形动画化花瓣

我们有一个背景,文本标签,以及吸气和呼气的呼吸效果;现在让我们将花瓣添加到场景中。我们可以将花瓣代码放在一个单独的文件中,因此按Command + N来打开PetalView

这里的目标是让五个花瓣沿着弧线移动,以便它们可以打开和关闭。为此,我们只需要两个变量,所以这将是一个非常小的文件:我们需要一个布尔值(Bool)变量来跟踪动画,还需要另一个变量来保存每个花瓣想要的旋转次数。现在让我们添加它们:

    @Binding var petal: Bool
    var degrees: Double = 0.0

被称为 petalBinding 变量将处理动画。我们使用 Binding 包装器,因为我们处于一个新的结构体中,并且我们还需要在另一个结构体 ContentView 中使用这个变量。当我们用一个 Binding 包装器作为前缀来标记一个变量时,我们就可以将其(绑定)用于另一个结构体或视图。

现在让我们进入 body 属性并创建一个花瓣。添加以下代码:

struct PetalView: View {
  var body: some View {
      Image("petal").resizable().frame(width: 75, height:
        125)
          .rotationEffect(.degrees(petal ? degrees :
            degrees), anchor: .bottom)
          .animation(Animation.easeInOut(duration: 
            2).delay(2).repeatForever(autoreverses: true),
            value: petal)
  }
}

只需查看三行代码:

  • 首先,我们将从资产目录中添加花瓣图像到场景中,并适当地调整其大小。

  • 接下来,我们使用 rotationEffect 修饰符通过选择两个 degree 值来打开和关闭花瓣:一个用于花瓣打开,一个用于关闭。我们还把旋转点锚定在花瓣的底部,这样花瓣就可以沿着弧线打开和关闭。

  • 然后,我们简单地调用 animation 修饰符来添加动画。同样,我们继续将 durationdelay 设置为 2 秒,并将 autoreverses 设置为 true

要在预览中看到花瓣图像,让我们修改 previews 结构体,使其看起来像这样:

struct PetalView_Previews: PreviewProvider {
    static var previews: some View {
        PetalView(petal: .constant(true))
    }
}

PetalView_Previews 结构体中的唯一变化是,我们使用 petal 参数并传入 .constant(true) 的值。这使 petal 变量变为 true,从而启用预览以显示 PetalsView 结构体的内容。

现在让我们回到 ContentView,并调用这个新的 PetalView 结构体五次来显示所有五个花瓣:

//MARK: - ANIMATE FLOWER PETALS IN AN ARC
    Group {
        PetalView(petal: $petal, degrees: petal ? -25 : -5)
        ///middle petal does not move
        Image("petal").resizable().frame(width: 75, height:
          125)
        PetalView(petal: $petal, degrees: petal ? 25 : 5)
        PetalView(petal: $petal, degrees: petal ? -50 :
          -10)
        PetalView(petal: $petal, degrees: petal ? 50 : 10)
    }

代码调用 PetalView 四次来在 UI 中添加四个花瓣,然后通过调用 Image 初始化器添加第五个花瓣(中间的一个),因为这个花瓣不会进行动画处理。结果如下所示:

图 8.4:花瓣

图 8.4:花瓣

注意 petal 参数的美元符号语法($):这是我们如何在另一个结构体中使用 Binding 变量的方式。在 degrees 参数中,我们有两个值:再次,一个用于花瓣打开时,另一个用于花瓣关闭时。当绑定 $petal 属性为 true 时,使用左侧的 degrees 值,当 $petal 属性为 false 时,将使用右侧的 degrees 值。

再次,中间的花瓣不会进行动画处理,所以我们只需要调用 Image 初始化器并设置其大小。其余的花瓣使用相同的代码;唯一的变化是 degrees 参数的值。

这样就完成了花瓣的设置。让我们通过在 onAppear 方法中切换 petal 变量来查看它们的动画效果,就像我们处理其他变量一样:

.onAppear {
            petal.toggle()
            breatheInLabel.toggle()
            breatheOutLabel.toggle()
            offsetBreath.toggle()
            diffuseBreath.toggle()
        }

花瓣按照我们设置的打开和关闭——到一个特定的点,沿着弧线旋转,然后再次关闭:

图 8.5:动画的花瓣

图 8.5:动画的花瓣

我们的项目中还有三个组件需要添加:

  • 直接位于花瓣正上方的花束

  • 围绕着花朵的移动呼吸

  • 背景中下雪

在下一节中,我们将实现前两点:添加花束和移动呼吸效果。

添加花束和动画呼吸

添加第一个组件,即花束,相对简单,因为我们已经做过类似的事情;然而,第二个组件,即移动呼吸,稍微有点棘手(但不用担心,我们会慢慢来,一切都会解释清楚)。

因此,首先,我们来添加花束。在 ContentView 中,在花瓣组的闭合括号之后,添加以下代码:

 //MARK: - ADD A BOUQUET OF FLOWERS AND MAKE THEM EXPAND 
   AND CONTRACT SO THEY APPEAR TO BE BREATHING 
    Group {
      Image("bouquet").resizable()
      .aspectRatio(contentMode: .fit)
            .frame(width: 300, height: 400)
            .rotationEffect(.degrees(37))
            .offset(x: -25, y: 90)
        ///breathe the bottom bouquet 1
            .scaleEffect(breathTheBouquet ? 1.04 : 1,
              anchor: .center)
            .hueRotation(Angle(degrees: breatheTheBouquet ?
              50 : 360))
            .animation(Animation.easeInOut(duration: 
              2).delay(2).repeatForever(autoreverses: 
              true), value: breatheTheBouquet)

      Image("bouquet").resizable()
        .aspectRatio(contentMode: .fit)
            .frame(width: 300, height: 400)
            .rotationEffect(.degrees(32))
            .offset(x: -20, y: 95)
            .rotation3DEffect(.degrees(180), axis: (x: 0, 
              y: 1, z: 0))
        ///breathe the bottom bouquet 2
            .scaleEffect(breatheTheBouquet ? 1.02 : 1, 
              anchor: .center)
            .hueRotation(Angle(degrees: breatheTheBouquet ? 
              -50 : 300))
            .animation(Animation.easeInOut(duration: 
              2).delay(2).repeatForever(autoreverses: 
              true), value: breatheTheBouquet)
    }

这看起来像很多代码,但实际上,我们使用非常相似的代码创建了两个花束。我之所以使用两个花束并将它们重叠,是为了创造一束完整花朵的错觉。

现在我们来看看代码。我们首先使用分组组织功能,并在组内为两个花束添加图片。我将纵横比设置为 fit,这样花束图片就能保持其比例大小,并用 300 x 400 的值来框定图片。

接下来,我们对每个花束调用 rotation 修改器,这样我们就可以将它们旋转到场景中我们想要的正确角度;第一个花束 37 度的旋转看起来不错,而第二个花束的 32 度则更适合。然后,每个花束在 xy 轴上稍微偏移一点,这样它们就能整齐地位于呼吸的正上方。这个位置将有助于隐藏呼吸,因为我们不希望在动画从花束上升起之前看到它。

下一行将花束稍微放大。这样做的原因是我们想要创建花束和花瓣一起呼吸的效果。顶部花束放大到 1.04,底部花束放大到 1.02。注意顶部花束放大得比底部花束稍多:这是因为我们想要稍微错开两个花束。

然后,anchor 被设置为 center,这样两个花束就会从这个点开始扩张和收缩。

下一行为花束的颜色添加了一些色调旋转,这样它们就会随着动画而改变颜色。我们之前在 第五章 中使用过 hueRotation 修改器,所以这里没有新内容,但我们使用不同的 hueRotation 值来帮助使花束的外观更加多样化。

还要注意,我们只在底部的花束上使用了rotation3DEffect修饰符。这是因为顶部和底部的花束图像是相同的;它们只是我们在两个地方使用的一个图像,所以通过在这里使用rotation3DEffect修饰符并传递y参数的值为1,这将沿着y轴翻转花束图像到花束的对面。这有助于使整体外观看起来更加对称。

最后,我们为两个花束添加动画;这与之前视图所进行的动画相同,持续时间为 2 秒,延迟时间为 2 秒,因此所有动画都是同步的。

现在,为了看到这个动画的实际效果,我们再次需要将负责花束的动画变量添加到onAppear修饰符中。因此,将以下代码添加到onAppear修饰符中:

}.onAppear {
            breatheInLabel.toggle()
            breatheOutLabel.toggle()
            offsetBreath.toggle()
            diffuseBreath.toggle()
            petal.toggle()
            breatheTheBouquet.toggle()
        }

现在,如果我们运行应用,我们将看到两个花束轻微地膨胀和收缩,花瓣也在移动。你还会看到花束的颜色变化,这是由于我们使用的hueRotation修饰符造成的。

图 8.6:花束的移动

图 8.6:花束的移动

花束的膨胀和收缩是一种微妙的外观(它不像花瓣的打开和关闭那样明显),这正是我们在这里追求的外观;我们只想创建一种轻微的膨胀效果。

在完成这些并使应用看起来已经相当不错之后,我们只需要向项目中添加一个额外的组件:雪。

在场景中创建下雪效果

在你的应用中添加下雪效果真的能让它栩栩如生:它给应用带来了一种相当神奇的外观,并为我们的冬季场景营造了正确的氛围。为了做到这一点,我们需要利用 UIKit 的强大功能和UIViewRepresentable协议,以及CAEmitter类。

注意

随着你继续阅读本章的其余部分,如果你发现一些 UIKit 类和方法与我们所使用的 SwiftUI 类和方法看起来不同或陌生,请不要担心。这是因为它们确实不同,如果你想要了解 UIKit,那将是另一本书的内容。然而,在本章的后续内容中,我将解释用于制作动画雪花的不同 UIKit 属性和方法,这样你可以熟悉这个过程。

添加 UIViewRepresentable 协议

UIViewRepresentable协议被称为 UIKit 的包装器。它允许 SwiftUI 与 UIKit 协同工作,并使用 UIKit 的类和方法。如果你曾经使用过 UIKit 进行编码,你会知道它与 SwiftUI 相当不同。首先,它使用一种称为Storyboard的东西,这是一种通过从对象库(如按钮、滑块和文本)拖放对象并连接它们到 Xcode 中的大 Storyboard 上来设计布局和组织视图的不同方式。所有这些对象的位置也完全不同,并使用一种称为Auto Layout的系统,这是一个方法和规则系统,用于保持对象之间的间距并定位在屏幕上,但它非常复杂,学习曲线很大。

SwiftUI 消除了 Storyboard 和 Auto Layout,并且在设计和应用构建方面与 UIKit 完全不同。它更简单,并且使用更少的代码就能达到相同的效果(正如你可能已经知道的)。但是,偶尔,我们需要访问 UIKit 提供的某些方法和类,以便在我们的应用中做不同的事情——例如,在这里,制作雪花。这就是为什么我们需要UIViewRepresentable协议的原因。

UIViewRepresentable协议在 UIKit 和 SwiftUI 这两个不同的框架之间充当桥梁,使我们能够访问所需的类和方法。

因此,让我们创建一个新的文件来包含雪花代码。按Command + N创建一个名为SnowView的 SwiftUIView 文件。然后,在结构体的顶部,我们将修改其标题以使其符合UIViewRepresentable协议。因此,将结构体的标题更改为以下内容:

//MARK: - CREATE SNOW FALLING ON THE SCENE
struct SnowView: UIViewRepresentable { 
         }

当我们将UIViewRepresentable协议添加到结构体的标题(冒号之后)时,这告诉系统我们现在可以允许使用 UIKit 框架中的类和方法。这也意味着我们必须实现这个协议所需的方法。有些协议只需要你在结构体标题中声明它们,就像我们刚才做的那样,但其他协议还需要你实现一些方法以满足协议的要求。

处理方法和错误

UIViewRepresentable要求我们在结构体中添加两个方法:makeUIViewupdateView。但由于我们还没有添加这些方法,我们刚才所做的更改将破坏我们的代码并显示错误:SnowView 不遵循协议 UIViewRepresentable。这是真的,因为,如前所述,我们需要实现两个方法来满足这个协议。

现在,让我们添加第一个方法,makeUIView,通过在SnowView结构体中添加以下代码来实现:

func makeUIView(context: Context) -> some UIView {
    }

makeUIView方法返回UIView,它将包含雪花,因此我们可以在ContentView中使用它。

我们需要添加的第二个方法被称为updateView,我们可以在上一个方法下直接实现它:

func updateUIView(_ uiView: UIViewType, context: Context) {
    }

此方法用于我们想要用新数据更新视图时。由于我们不需要在制作雪花时进行任何更新,我们可以留空此方法。然而,它是一个必需的方法,所以它确实必须存在于SnowView结构体中;否则,代码将无法工作。

现在我们已经有了两个必需的方法,协议应该得到满足。然而,我们仍然得到一个错误:makeUIView方法以及它在体内没有返回代码的事实。为了消除错误,makeUIView方法需要返回UIView(或者简单地说,一个视图)。所以,让我们在makeUIView方法中添加代码,以返回一个视图,这将反过来制作我们的雪花。

首先,我们需要设置视图的大小以正确适应屏幕。记住,我们正在使用 UIKit 的类和方法创建一个新的视图,所以我们必须告诉它这个视图的屏幕尺寸应该是多少。将以下代码添加到makeUIView方法中:

    func makeUIView(context: Context) -> some UIView {
    //configure the screen
            let screen = UIScreen.main.bounds
            let view = UIView(frame: CGRect(x: 0, y: 0, 
              width: screen.width, height: screen.height))
            view.layer.masksToBounds = true
        }

第一行代码创建了一个常量,它将保存屏幕的边界——即从上到下和从左到右的屏幕矩形。

代码的第二行创建了一个视图,这是我们完成此方法编写后需要返回的视图。我们将视图设置为屏幕的宽度和高度,这样当我们制作雪花时,我们就会使用 iPhone 屏幕的整个边界,而不仅仅是其中的一小部分。

下一行代码使用了masksToBounds布尔属性并将其设置为truemasksToBounds属性指示系统是否将子层裁剪到层的边界内。

ZStack,你放置在其内的每个视图都会堆叠在先前的视图之上。

因此,在所有这些视图堆叠起来之后,可能会有一些你不想显示的视图部分,以及你想要显示的同一视图的其他部分。maskToBounds属性将裁剪任何子视图到屏幕的边界,这很有帮助,因为我们只想让雪花在屏幕大小内下落;我们不想让任何雪花出现在屏幕外的任何周围区域,只希望在屏幕的边界内,所以我们设置此属性为true以裁剪掉任何多余的。

现在,我们需要返回一个视图以消除这个错误。记住,makeUIView方法在其声明中有一个return语句,所以请在我们之前的代码末尾添加以下代码:

func makeUIView(context: Context) -> some UIView {
    //configure the screen
            let screen = UIScreen.main.bounds
            let view = UIView(frame: CGRect(x: 0, y: 0, 
              width: screen.width, height: screen.height))
            view.layer.masksToBounds = true

    return view
  }

现在,我们已经设置了屏幕大小,我们正在返回一个视图,并且没有错误!下一个任务是创建雪花,我们可以通过使用 UIKit 的CAEmitter类来完成。

添加 CAEmitter 类

CAEmitter(代表Core Animation Emitter)是一个类,它具有让我们发射、动画和渲染粒子系统的方法和属性。

那么什么是粒子系统?粒子系统就是它听起来那样:一个可以在 iPhone 屏幕上产生数百或数千个小粒子、任何大小、任何速度以及任何形状的系统。

我们想要的粒子形状已经设计好了,因为我们将会使用我们将会放入资产目录中的snowflake图像。然而,我们必须设计制作多少雪花,它们移动的速度有多快,它们在屏幕上停留的时间有多长,以及它们在屏幕上的起始位置。

因此,让我们继续创建一个emitter实例,使用CAEmitter类。在masksToBounds代码行下方添加以下代码:

   //configure the emitter
    let emitter = CAEmitterLayer()
    emitter.frame = CGRect(x: 200, y: -100, width: 
      view.frame.width, height: view.frame.height)

第一行使用CAEmitter类创建了一个emitter实例。

下一行设置了发射器的位置和大小。在这里,我们在x轴上定位发射器200个点,大约是 iPhone 屏幕的中间位置,以及在y轴上-100个点。这使得发射器在 iPhone 屏幕上方 100 个点处。我们放置发射器在屏幕的可视部分上方,因为我们不想看到雪花被创建的过程,我们只想看到它们下落。

至于发射器的大小,我们将其设置为与 iPhone 屏幕完全相同的大小,这意味着使用frame.widthframe.height值。

现在我们已经设计好了发射器,让我们来设计细胞。为了解释这两个术语之间的区别,可以把发射器想象成一个盒子,里面装着成千上万的纸屑粒子,而每一个这样的纸屑粒子就是一个细胞。在我们的例子中,我们的细胞将是一个雪花。

要添加我们的细胞并使其在屏幕上移动,请在发射器代码下方添加以下代码:

        //configure the cell
        let cell = CAEmitterCell()
        cell.birthRate = 40
        cell.lifetime = 25
        cell.velocity = 60
        cell.scale = 0.025
        cell.emissionRange = CGFloat.pi
        cell.contents = UIImage(named: "snow")?.cgImage
        emitter.emitterCells = [cell]
        view.layer.addSublayer(emitter)

在这里,我们使用CAEmitterCell类创建了一个cell实例,并为其加载了不同的属性。以下是一个列表,说明了这些属性的作用:

  • birthRate:每个细胞或粒子被创建的速度有多快。使用40的值模仿了轻柔的雪落。

  • lifetime:在移除细胞之前,它在屏幕上保持多长时间。使用25的值确保每一片雪花在屏幕上停留足够长的时间,以便到达屏幕底部。

  • velocity:我们希望细胞在屏幕上移动多快。我们使用60的值,这是一个既不太快也不太慢的速度,适合雪花。

  • scale:我们希望每个细胞有多大。对于雪花来说,.025是一个很好的大小,因为它既不太大也不太小,适合 iPhone。

  • emissionRange:我们希望细胞在发射时扩散多远。对于这个值,我们使用了一个叫做π的数学表达式(通常用符号π表示),它定义为圆的周长除以同圆的直径。在这里不深入数学的话,CGFloat.pi的值等于180度——想象一下从手机屏幕的左侧水平画到右侧的水平线;这个值设置了雪花从这条线的所有区域均匀下落。

  • contents:将单元格的内容设置为我们的选择之一,这里设置为snow图像(确保您已经将图像添加到资产目录中,以便可以通过此行代码访问)。

  • emitterCells:将成为雪的单元格粒子。

  • addSublayer:向场景中添加一个新层。记住在 SwiftUI 中,层非常类似于视图,SwiftUI 中的一切都是视图(按钮、文本、颜色等等)。在 UIKit 世界中,层也可以被视为视图——当调用addSublayer函数时,它将参数中的任何层添加到场景中。

和往常一样,这些单元格配置值完全是任意的,所以您可以继续实验,让雪花变得尽可能大,按照您想要的样式设计它们,并让它们在屏幕上按照您想要的动作移动。在单元格中添加值时没有固定的规则;一切都关于实验和乐趣。

就这样,我们已经完成了雪文件。如果您想测试结果,可以点击播放;然而,由于预览的默认背景是白色,您将看不到我们的白色雪花。您可以通过交换背景颜色来改变这一点,如下所示:

struct SnowView_Previews: PreviewProvider {
    static var previews: some View {
        SnowView()
            .background(Color.black)
    }
}

这样可以将背景颜色改为黑色,这样您就可以看到雪花;然而,正如我们所知,我们将使用冬季背景作为最终动画。

因此,为了完成整个项目,我们需要在ContentView内部调用这个SnowView文件。要做到这一点,请回到ContentView并在背景代码下方添加这一行代码:

//MARK: - ADD A WINTER BACKGROUND - AND THE SNOW
  Image("winterNight").resizable().aspectRatio(contentMode: 
    .fill).frame(width: 400, height: 900)
  SnowView()

这样就完成了项目。现在您可以继续运行它:

图 8.7:完成的项目

图 8.7:完成的项目

该项目包含呼吸标签动画、呼吸花瓣动画、呼吸花束动画和雪花动画。

摘要

在这个项目中,我们通过使用一系列编码修饰符来创造花朵呼吸的错觉:我们使用blur来创造烟雾的错觉,使用scalerotationEffect使花朵在弧线上膨胀和收缩,我们还添加了屏幕上的标签。在此基础上,我们使用了UIViewRepresentable协议和CAEmitter类,并整合了粒子系统来创建下落的雪花。

这里有一些额外的想法,关于如何进一步开发这个应用,或者只是练习添加更多功能,以帮助您通过 SwiftUI 动画来扩展您的技能。比如给项目添加一些声音怎么样?我们在第四章中的留声机项目中就做到了这一点,而且非常简单——您可以添加一些指导性的语音解说,比如“吸气,呼气”,或者一些冥想音乐。

或者,添加一个按钮或滑块来改变动画的速度怎么样?也许你想增加花瓣的开合速度。如果你不确定如何做到这一点,请继续阅读这本书,因为稍后,我们将构建一个使用按钮和滑块的 UI 颜色游戏。

让我们继续到下一章,我们将看到如何使一个stroke修饰符动画化,使其围绕任何形状创建移动的线条。

第九章:在形状周围动画线条

在这个项目中,我们将取三张图像,围绕它们创建轮廓,然后沿着这些轮廓动画线条。线条是跟随形状轮廓(或轮廓线)的线条,我们可以给它任何颜色和粗细,并动画化使其在图像周围移动。

要做到这一点,您将学习如何使用 Inkscape 软件将位图图像转换为矢量图像,然后使用 Sketch 和 Kite 将这些矢量转换为 Swift 代码。然后我们将此代码插入 Xcode 中,以便我们可以开始动画化我们的项目。

以下是这个项目的目标:

  • 将图像转换为 Swift 代码

  • 使用stroke修饰符动画化图像

技术要求

您可以从 GitHub 上的第九章文件夹下载资源和完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

您还需要以下内容:

将图像转换为 Swift 代码

为了创建我们需要的三个轮廓,我们首先必须将图像转换为矢量,然后将这些矢量转换为 Swift 代码。为什么我们必须将图像转换为 Swift 代码?

好吧,为了在图像周围放置动画线条,我们需要一个动画路径。当图像被转换为代码后,使用 Swift 跟随图像轮廓将变得容易。

然而,有一个问题:位图。位图,也称为栅格图像,是由不同颜色的像素组成的图形,这些像素组合在一起形成图像。位图可以非常简单,仅由两种颜色组成(那些将是黑色和白色),或者它们可以有数千或数百万种颜色,产生高质量的图像。您将看到的位图格式示例使用文件扩展名 PNG、JPEG 和 TIFF。位图图像没有定义的轮廓供任何代码跟随,因此它不能在其周围有动画线条。

矢量图像,另一方面,是由代码创建的图像,具有锐利的边缘,并且在放大时永远不会像位图那样模糊。由于它们是用代码创建的,因此我们可以围绕这些图像动画线条。

如果您想围绕位图图像动画线条,您需要执行以下三个主要步骤:

  • 移除您想要使用的图像的背景

  • 将图像矢量化为 SVG 文件

  • 将 SVG 文件转换为 Swift 代码

那么,我们现在就按照这些步骤进行。

移除图像背景

许多图像都有白色或彩色背景,在很多情况下,背景是不必要的。如果图像是在透明背景上,转换为矢量图会更容易。

在我们的项目中,我们将使用的是“WE”这个词的图像、一个心形图像和 SwiftUI 标志的图像。我们将使用 Swift 将它们组合起来,使它们表达“我们爱 SwiftUI”,如下所示:

图 9.1:我们项目中将使用的图像

图 9.1:我们项目中将使用的图像

你可以从 GitHub 仓库中下载这些图像,你将注意到背景已经被移除了。然而,如果你打算使用自己的图像,你需要自己完成这项操作。

当我们为秋千上的女孩动画时,我们已经探讨了如何在第六章中移除背景。你可以参考那一章;然而,为了回顾,你可以使用一个名为 Remove Background 的在线工具:www.remove.bg/upload。你只需点击上传图像并选择你想要使用的图像。就这样——大约 20 秒后,网站的算法将找到背景并将其移除,只留下你的主题。然后,你只需下载新的图像文件,就可以继续了。

或者,你可以使用 Mac 的预览应用来移除背景,或者你可以使用付费软件,如 Affinity Designer。你决定如何移除背景取决于你;尝试并看看哪种方法最适合你使用的图像。

此外,尽管这不是严格必要的,但裁剪你的图像是个好主意,因为我们只想处理图像本身,而不是任何周围的空白空间。你几乎可以在任何图形编辑程序中完成这项操作,包括预览应用。

现在你可以继续到下一步,即矢量化图像。

矢量化图像

第二步将是将位图图像转换为矢量图。为此,我们需要具有适当算法的软件来检测像素、移除它们,并用代码代替它们来绘制形状。生成的代码文件将是一个 .svg 文件。市场上最好的软件之一叫做 Vector Magic。这款软件易于使用,一键启动即可自动化矢量化过程,但缺点是它大约需要 300 美元。

或者,我们可以使用 Inkscape,这是一款免费软件,它能够很好地追踪位图图像并将其所有部分转换为矢量图。这就是我们将要使用的工具,你可以从以下链接下载最新版本(截至写作时):inkscape.org/release/inkscape-1.2.1/

小贴士

尽管 Inkscape 可以处理彩色图像,但通常最好将您的图像设置为黑白;这两种颜色算法效果更好。如果您有彩色图像,那也不是问题——您仍然可以使用像 Affinity Designer 这样的图形程序将它们转换为黑白。

如果您查看我们用于此项目的图像,其中有两个是彩色的,但这没关系,因为它们的颜色有限且形状简单。如果您有更复杂的形状,例如,包含许多颜色的肖像照片,那么最好先将它们转换为黑白。

一旦安装了 Inkscape,您就可以开始使用。选择您的一张图像(以我的情况为例,我选择了“WE”图像),右键单击它,并在 Inkscape 中打开。您将看到以下弹出窗口:

图 9.2:将图像导入 Inkscape

图 9.2:将图像导入 Inkscape

图像导入类型设置为嵌入图像 DPI设置为默认导入分辨率图像渲染模式设置为无(自动)。然后,点击确定。您将看到以下屏幕:

图 9.3:带有我们图像的 Inkscape 编辑器

图 9.3:带有我们图像的 Inkscape 编辑器

在继续转换之前,我想向您展示位图近距离的样子。在您的 Mac 触摸板上,捏合并放大图像,您将看到像素化和模糊出现。这是您判断图像是位图而不是矢量的方法之一。(当然,文件扩展名也会让您知道它是位图还是矢量!)

接下来,在 Inkscape 画布上选择您的图像(如果您看到它周围有黑色箭头,则表示已选择),然后转到屏幕顶部的路径菜单,并选择追踪位图。这将打开 Inkscape 右侧的预览选项:

图 9.4:Inkscape 预览

图 9.4:Inkscape 预览

预览窗口中,Inkscape 向我们展示了追踪位图功能将能够追踪的内容,以及图像作为矢量图形将看起来像什么。由于预览看起来几乎与画布上的“WE”图像相同,因此图像周围将有一个完整的轮廓。

在预览中,您目前还看不到矢量线条和点,但很快就会看到。

由于我们使用的是黑白图像,Inscape 已经从预览窗口的顶部选择了单次扫描选项(您可以在图 9.5中看到)。

图 9.5:Inkscape 扫描选项

图 9.5:Inkscape 扫描选项

单扫描选项用于黑白图像,但如果我们使用彩色图像,则需要选择多色选项。多色选项将扫描您的图像,查看其中包含的多种颜色,并尽可能为每种颜色绘制轮廓。这就是我之前说通常最好使用黑白图像的原因,因为扫描的颜色较少,因此无法获得良好轮廓的机会也较少。

图 9.5所示,您还可以调整阈值细节滑块以在预览中获得最佳图像。如果 Inkscape 难以获得良好的扫描,这些滑块非常有用。通过移动滑块,您可以微调 Inkscape 可以看到的图像部分。

让我们来看看细节滑块:

  • 斑纹滑块将忽略向量中的小点——当设置为最大时,它将忽略更多的斑纹,而当设置为最小时,它将忽略较少的斑纹。

  • 平滑角落滑块将平滑掉追踪中的任何尖锐角落。

  • 优化滑块将尝试通过连接相邻的贝塞尔曲线段来优化路径;这意味着优化滑块将尽力移除尽可能多的向量节点。当向量图像中的节点较少时,往往会有更好的追踪和较少的锯齿边缘。

我们将在创建围绕心脏的描边时看到阈值滑块。

我们希望在预览中看到尽可能接近中心画布中图像的图像。当我们看到这种情况时,这意味着 Inkscape 可以正确追踪画布上的形状并捕获所有向量线条。如果您没有看到接近原始图像的图像,那么请尝试调整我刚才提到的某些滑块以微调输出。

一旦您调整完图像,使其看起来接近原始图像,请点击更新预览,然后应用。Inkscape 将其新创建的向量直接放置在原始图像上方。要查看结果,请在编辑器中点击“WE”图像,并将新向量拖到一边:

图 9.6:创建向量

图 9.6:创建向量

现在您可以删除原始图像,因为它不再需要了。

让我们看看 Inkscape 为我们做了什么——它追踪了原始图像,并创建了一个由向量路径组成的新图像。为了查看差异,请在 Mac 触控板上用您查看位图图像的方式缩放和捏合新的向量图像。您看到差异了吗?图像完全没有像素化或模糊,因为图像是用代码而不是像素创建的;图像可以放大到任何大小,并且它的边缘和曲线仍然保持清晰:

图 9.7:向量分辨率

图 9.7:向量分辨率

现在,记得我提到过您将能够看到 Inkscape 创建的矢量吗?好吧,它们确实在那里,只是我们需要选择正确的编辑工具来查看它们。选择画布中央的图像,转到左侧工具栏,并选择节点编辑工具。当您点击该工具时,您将能够看到 Inkscape 创建的所有矢量线条:

图 9.8:矢量

图 9.8:矢量

这些小正方形中的每一个都是一个点(或节点),它将线条、曲线和角落连接起来,形成这个新形状——并且现在可以通过点击它们并将它们拖动到任何位置来编辑这些点,以重塑矢量图像。

在创建了新的矢量后,我们可以将其导出。为此,在顶部菜单中打开文件,然后选择导出。然后,您可以在软件的右下角选择导出选项。选择您想要导出的文件类型——我们想要一个纯 SVG (*.svg) 文件。然后,点击导出按钮:

图 9.9:导出为 SVG 文件

图 9.9:导出为 SVG 文件

现在,我们在电脑上存储了一个 SVG 文件,准备将其转换为 Swift 代码。对于您想要在这个项目中使用的任何其他图像,执行相同的过程——无论您是使用自己的图像还是跟随书中的项目图像——然后您可以继续下一步。

将 SVG 文件转换为 Swift 代码

让我们继续。我们有一个准备进行下一步的 SVG 文件。SVG 文件是一种以 XML 编写的文件,XML 是一种用于存储和传输数字信息的标记语言。SVG 文件中的 XML 代码创建了构成图像的所有形状、颜色和文本。我们将很快将那个 XML 代码转换为 Swift 代码,但首先,我们需要编辑图像的尺寸,我们可以在 Sketch 中完成这项操作。

因此,首先,在 Sketch 中打开 SVG “WE” 图像。然后,在右侧的实用工具区域,我们可以按自己的喜好调整图像大小;在我的情况下,我将宽度设置为250。要设置此值,请确保在宽度和高度字段之间的那个小锁形图标被选中,以便锁定——这将保持图像的正确比例——然后按Enter

图 9.10:调整图像大小

图 9.10:调整图像大小

在 Sketch 中,我们所需做的就这些了。现在,最小化您的 Sketch 文档(但不要关闭它!)。

接下来,我们需要一个可以将 XML 代码转换为 Swift 代码的程序。一个选项是 Paint Code——尽管这是一个很棒的程序,但每年订阅费用约为 200 美元。相反,我们将使用 Kite——尽管目前一次性购买价格为 99 美元,无需订阅,您始终可以使用免费试用版来跟随这个项目。

因此,打开 Kite。然后,点击文件 | 导入 | 从 Sketch…

图 9.11:从 Sketch 导入到 Kite

图 9.11:从 Sketch 导入到 Kite

你将看到一个弹出窗口询问你如何导入图层。保留默认设置不变,但请确保 导入文本图层为 设置为 图像图层,并且 导入图像的缩放 设置为 1x

图 9.12:从 Sketch 导入设置

图 9.12:从 Sketch 导入设置

然后,点击 导入。现在你的图像已经在 Kite 中,并且准备好将其转换为 Swift 代码,这个过程就像点击屏幕顶部的 代码 按钮一样简单:

图 9.13:将文件转换为 Swift 代码

图 9.13:将文件转换为 Swift 代码

生成的 Swift 代码显示在底部的控制台中,你可以通过向上拖动来调整它的大小,就像调整 Xcode 控制台一样。以下是“WE”图像的代码控制台外观,其中包含构成其长度、粗细、颜色、位置等所有 Swift 代码:

图 9.14:Kite 生成的 Swift 代码

图 9.14:Kite 生成的 Swift 代码

这看起来像是创建一个由两个字母组成的图像需要很多代码,不是吗?嗯,是的,但代码做了很多事——它绘制出创建线条和曲线形状所需的所有路径。

现在让我们将这段代码放入 Xcode 中。首先,如果你还没有创建一个新的 Xcode 项目,请创建一个——我将其命名为 Animating Strokes。然后,我们只需要从 Kite 中复制一小段代码。你应该从这里开始复制代码:

let pathPath = CGMutablePath()

然后继续向下浏览文件,直到你到达这两行:

 pathPath.closeSubpath()
 pathPath.move(to: CGPoint(x: 159, y: 104.854378))

因此,我们只想复制以 pathPath 开头的所有代码;其他我们不需要。

现在,在我们进入 Xcode 之前,让我们总结一下到目前为止我们所做的工作。我们取了一个位图图像,移除了它的背景并裁剪了它,用 Inkscape 打开它并将其转换为矢量图像,然后最后将这个矢量图像导入到 Kite 中,在那里我们将其转换为 Swift 代码。随着练习的增多,这个过程会变得更快。

现在让我们进入 Xcode 并开始让所有这些代码工作。

使用笔画修改器动画图像

在接下来的几节中,我们将在 Xcode 中工作,并对“WE”代码进行一些重构,以便使其在我们的项目中工作。我们将使用允许我们使用其 path() 函数绘制 2D 形状的 Shape 协议,然后使用 stroke 修改器在形状的路径周围添加移动的线条。我们还将开始动画我们的心形和 SwiftUI 标志图像。

在“WE”图像上创建笔画动画

要开始动画“WE”图像,我们需要在项目中创建一个文件。按 Command + N,选择一个 WeView

然后,在文件底部创建一个结构体;这将是一个符合形状协议的结构体,允许我们通过使用 path 函数创建 2D 形状:

struct WeTextShape: Shape {
    func path(in rect: CGRect) -> Path {
    }
}

现在,我们可以将我们从 Kite 程序中复制的代码粘贴到path函数中。这段代码相当重复且冗长,因为它在绘制形状时必须对每一行、曲线和角落重复。因此,为了简洁并节省空间,我只会提供代码的开始和结束部分(然而,你可以在 GitHub 仓库中找到完整的代码):

struct WeTextShape: Shape {
  func path(in rect: CGRect) -> Path {
    let pathPath = CGMutablePath()
      pathPath.move(to: CGPoint(x: 33.544579, y: 
        105.919167))
      pathPath.addCurve(to: CGPoint(x: 20.994799, y: 
        60.169167), control1: CGPoint(x: 32.057159, y: 
        101.184097), control2: CGPoint(x: 29.932928, y: 
        93.440262))
      pathPath.addCurve(to: CGPoint(x: 3.45976, y: 
        7.26897), control1: CGPoint(x: 8.184458, y: 
        12.4843), control2: CGPoint(x: 7.749718, y: 
        11.17275))
      pathPath.addLine(to: CGPoint(x: 0, y: 0.16917))
      ........................................................
      pathPath.addCurve(to: CGPoint(x: 236.485107, y: 
        93.6082), control1: CGPoint(x: 237.051361, y: 
        79.890518), control2: CGPoint(x: 236.884613, y: 
        86.149673))
      pathPath.addLine(to: CGPoint(x: 235.758789, y: 
        107.169159))
      pathPath.addLine(to: CGPoint(x: 197.379395, y: 
        107.169159))
      pathPath.addLine(to: CGPoint(x: 159, y: 107.169159))
      pathPath.addLine(to: CGPoint(x: 159, y: 104.854378))
      pathPath.closeSubpath()
      pathPath.move(to: CGPoint(x: 159, y: 104.854378))
  }
}

当你将代码粘贴到 Xcode 中时,你将得到一些错误——不要担心这些错误,因为代码需要稍作修改。

解决这些问题也是我向您展示如何在 SwiftUI 中重构代码的好借口。重构是一个有用的功能,它允许我们在项目的多个文件中重命名代码或删除和替换代码的不同部分,而无需搜索所有文件以查找需要重构的每个实例并手动更改它。

我们想要更改的一件事是pathPath常量的名称,这是粘贴到path函数中的第一行代码。这个常量似乎被命名了两次,因为它是从 Inkscape 导出的。然而,它应该简单地命名为path

因此,为了重构这段代码,只需Command + click点击pathPath变量,并在文件中选择pathPath,然后你只需输入新的名称path并按Enter键。

这就是我们对复制的代码所做的第一次修订。下一次修订是更改分配给path常量的类。目前,path常量被分配为一个CGMutablePath()实例,这是一个用于绘制形状和线条的核心图形类。但在 SwiftUI 中工作,几乎所有内容都是用结构体构建的,因此我们需要使用Path()结构体。此外,将let关键字更改为var关键字,因为我们需要path变量是可变的,这意味着其值可以被更改。

完成这些修改后,你的path函数中的第一行代码应该如下所示:

var path = Path()

现在,让我们向这个path函数添加一行代码。在函数的末尾,我们需要返回path变量,为此,我们使用return关键字,后面跟着函数必须返回的变量;因此,在path函数的底部添加此行:

return path

以下是我们对path函数开始和结束部分所做的更改:

func path(in rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 33.544579, y: 105.919167))
    path.addCurve(to: CGPoint(x: 3.45976, y: 7.26897), 
      control1: CGPoint(x: 8.184458, y: 12.4843), control2: 
      CGPoint(x: 7.749718, y: 11.17275))
       •••SHAPE CODE HERE•••
    return path
  }

当你按下Command + B时,代码应该是无错误的。

现在,让我们看看所有这些 Kite 代码实际上在做什么。

path()函数将根据在内部绘制的点、线和曲线返回一个形状。path()函数内的第一行代码是创建一个路径实例;这将绘制形状,你可以将这个变量想象成在画布上移动的铅笔。

代码的第二行是move()函数。这是设置path变量移动到起始位置的方法。位置基于笛卡尔坐标系,使用 X 和 Y 位置来定位path变量。

在第三行代码中,path变量调用addCurve()函数,它做了它所说的——使用指定的点在路径上添加一个贝塞尔曲线。继续向下查看我们的代码,我们看到path变量还调用了addLine()函数,这将使用指定的点在路径上添加一条线。代码继续调用这两个函数,addCurve()addLine(),直到path变量需要完成绘图中的特定路径并在移动到绘图中的下一行之前关闭完成线的末端。

使用closeSubpath()函数关闭完成线条,然后直接使用moveTo()函数开始绘制新的一行。所以,正如你所看到的,在这段代码中有几行用来组成完成形状。

最后,在所有这些代码的末尾,path()函数返回包含完成形状的path变量,现在它可以在 iPhone 屏幕上显示。

接下来,继续在同一文件中工作,我们需要进入WEView结构体并添加代码以显示此形状。

从结构体的顶部开始,添加以下变量:

//MARK: - VARIABLES
    @State var strokeReset: Bool = true
    @State var startStroke: CGFloat = 0.0
    @State var endStroke: CGFloat = 0.0

接下来,在body属性内部,添加一个ZStack

var body: some View {
    ZStack {
    }
  }

ZStack内部,让我们添加一个Group,然后添加以下代码以在预览中显示形状:

 ZStack {
        Group {
            //SHAPE OUTLINE
            WeTextShape()
                .stroke(style: StrokeStyle(lineWidth: 0.5, 
                  lineCap: .round, lineJoin: .round))
                .foregroundColor(.gray)
        }
    }

Group内部,我们调用WeTextShape结构体,并直接对其添加stroke修饰符。这个修饰符将创建一个新的形状,其上有一个带有样式的描边,该样式根据StrokeStyle函数进行设置。StrokeStyle函数添加了一个lineWidth属性值为0.5,并将lineCap(描边的末端)和lineJoin都设置为round;这通过半圆形弧线连接描边的末端。最后,描边形状的颜色被设置为gray

这完成了形状的创建——我们不会为它添加任何动画,因为我们只想让它保持静止。相反,我们将在第一个形状的正上方再次添加这个形状,这是我们将在颜色上动画化的形状。现在让我们添加它。

Group视图内部继续,并在最后一行代码下方直接添加以下内容:

    Group {
        //SHAPE OUTLINE
        WeTextShape()
            .stroke(style: StrokeStyle(lineWidth: 0.5, 
              lineCap: .round, lineJoin: .round))
            .foregroundColor(.gray)
        //ANIMATING STROKE
        WeTextShape()
            .trim(from: startStroke, to: endStroke)
.stroke(style: StrokeStyle(lineWidth: 5, 
              lineCap: .round, lineJoin: .round))
            .foregroundColor(Color.red)
    }.offset(x: 75, y: 50)
  )
   }

这就是将在第一个形状正上方添加第二个形状,与第一个形状完全相同的代码。

就像我们对非移动形状所做的那样,我们调用了 WeTextView 结构体来创建形状。然后,我们使用 trim 修饰符根据传递给其参数的值以分数形式修剪形状。通过传递 startStroke 变量作为笔触动画的起始位置,以及 endStroke 变量来告诉 trim 修饰符在哪里停止,将在形状路径上从起点到终点绘制一条笔触线。这条笔触线的长度将取决于 startStrokeendStroke 变量内部的值。

就像我们对第一个形状所做的那样,我们也使用了 strokeStyle 修饰符来使用 width 属性为 5width 属性和将 lineCaplineJoin 设置为 round 来样式化笔触。这次,我们将颜色设置为 red,以便移动的笔触在沿着其下方的灰色形状移动时更加突出。

然后,最后,我们使用 offset 修饰符将形状在预览中从左到右居中,并朝向 iPhone 的顶部。

现在,轮廓和笔触动画的代码已经完成。接下来需要使用 onAppear 修饰符来启动动画并设置一些计时器,以便笔触可以以一定的速度进行。在 ZStack 的闭合花括号后添加最后一段代码:

.onAppear() {
    Timer.scheduledTimer(withTimeInterval: 0.23, repeats: 
      true) { timer in
        if (endStroke >= 1) {
            if (strokeReset) {
                Timer.scheduledTimer(withTimeInterval: 0.6, 
                  repeats: false) { _ in
                    endStroke = 0
                    startStroke = 0
                    strokeReset.toggle()
                }
                strokeReset = false
            }
        }
        withAnimation(Animation.easeOut) {
            endStroke += 0.12
            startStroke = endStroke - 0.4
        }
    }
  }

这段代码一开始可能看起来有些复杂,但让我们来分解它以便理解。

ZStack 容器上调用 onAppear() 方法;这个方法在视图出现在屏幕上时被触发。在方法内部,使用 Timer.scheduledTimer 方法安排了一个计时器,设置为每 0.23 秒重复一次。这是动画绘制每个笔触段所需的时间。笔触段是我们一次绘制形状的方式,一次绘制一个段。你可以尝试调整这个计时器的间隔值——较大的数字将使笔触段绘制得更慢,而较小的数字将使段绘制得更快,从而在形状周围创建一条平滑的动画线。

接下来,我们希望动画能够重复,因此将 repeats 参数设置为 true。在计时器的处理程序中,代码首先检查 endStroke 变量是否大于或等于 1。如果是这样,代码接着检查 strokeReset 变量是否为 true。如果这两个条件都满足,就使用 Timer.scheduledTimer 方法安排另一个计时器。这个计时器设置为在 0.6 秒后只运行一次,在这个计时器的处理程序中,将 endStrokestartStroke 的值重置为 0,这样笔触动画就可以再次从开始处开始,并且 strokeReset 变量被切换。在安排内部计时器后,将 strokeReset 变量设置为 false

withAnimation函数与我们过去项目中使用的动画修饰符不同。记得在第二章,当我们讨论两种类型的动画,隐式显式时?withAnimation函数是一种显式类型的动画,用于动画化视图状态的变化。它被认为是一种显式动画,因为它要求你指定你想要使用的动画,而不是隐式动画,后者会自动动画化变化,无需额外的代码。

因此,在withAnimation函数的主体中,我们在动画的每次迭代后都将endStroke变量的值增加0.12。我们还设置了startStroke的值,该值是通过从endStroke中减去0.4计算得出的——这种计算方式创建了一个动画笔画的长度和速度,我认为对于“我们”形状来说看起来相当不错。

有了这些,这个动画就完成了。所以,为了总结WeView结构体中的代码,它创建了一个动画形状的视图,这个形状被勾勒了两次,一次是灰色和细笔画,然后再次,是红色和大笔画。动画由startStrokeendStroke变量控制,这些变量随时间增加。一旦endStroke变量达到1,动画就会重复一次,并且在动画再次开始之前有一个0.6秒的延迟。

现在我们已经完成了代码,如果你点击withAnimation函数中的endStroke,你可以根据你想要的视觉效果在形状上留下更多的笔画线或者更少。

尝试调整数值并进行实验。你可以通过增加速度来创建闪烁的笔画,或者你可以有一个非常缓慢移动的笔画。你可以用短线条勾勒整个形状,或者你可以沿着整个形状使用非常长的线条。

这完成了我们的第一个动画,“WE”图像的笔画。尽管这个过程看起来很复杂,但一旦你熟悉了不同的程序和技术,整个过程实际上只需要几分钟。我们现在将转向心形图像,并重复同样的过程。毕竟,熟能生巧!

在心形图像上创建笔画动画

好的,我们已经动画化了我们的第一张图像,所以我们将对另外两张图像重复同样的过程。下一张图像将是一个心形——对于这个,我们将像处理字母一样在它周围动画化一个笔画,但也会将心形图像重新添加到场景中。

如果你正在跟随书中的项目,你可以在 GitHub 仓库中找到心形图像,其中背景已经被移除。然而,如果你使用自己的图像,你需要自己完成这项工作。

在你准备好了你的图像后,现在用 Inkscape 打开它,以便你可以将其矢量化。在编辑器中选择图像后,转到顶部的路径菜单并选择追踪位图;这将追踪图像并为其矢量化做准备,就像之前一样。

在画布上选择图像后,如果你查看右侧的预览面板,图像已被转换为黑白。这是因为默认情况下,Inkscape 选择单扫描选项,黑白图像的选项。尽管这是一个彩色图像,但由于它是一个形状简单且红色变化不多的图像,我们不需要多彩色选项;所以,单扫描在这里就足够了。

这也是一个很好的机会来使用平滑角落选项,因为这个形状(大部分)有平滑的角落,这个选择将保留它们。

这就是到目前为止应该看起来的样子:

图 9.15:Inkscape 中的心形图像

图 9.15:Inkscape 中的心形图像

此外,你注意到在预览中显示的心形图像内部有两个白色区域吗?那是 Inkscape 算法试图捕捉画布上红色心形图像的闪亮部分。我们只想追踪心形的轮廓,这是稍后在代码中将要勾勒的区域——我们不想那些白色区域。为了在预览中移除这些白色区域,将阈值滑块向右滑动,直到心形填充得像这样:

图 9.16:在 Inkscape 中设置心形图像的选项

图 9.16:在 Inkscape 中设置心形图像的选项

现在心形形状已经准备好进行矢量化,点击应用按钮以完成过程,一个新的矢量化黑色心形将直接放在画布上的红色心形图像上方。将黑色心形拖到一边,以便可以看到下面的红色心形,然后删除红色心形。

接下来,将黑色心形放回到白色画布上,并像“WE”图像一样导出它,确保选择纯 SVG (*.svg)文件类型。

离开 Inkscape 并进入 Sketch 程序,通过右键单击图像并选择用 Sketch 打开来将我们从 Inkscape 导出的新矢量图像打开到 Sketch 中。我们将再次将图像调整到宽度为250(再次确保点击锁定图标以固定比例):

图 9.17:在 Sketch 中设置心形图像的大小

图 9.17:在 Sketch 中设置心形图像的大小

接下来最小化 Sketch 窗口,因为我们只需要它在后台运行,以便 Kite 可以访问它,然后打开 Kite 程序。在 Kite 中,点击文件,然后导入,接着从 Sketch 导入。你会看到与图 9.12中显示的相同窗口,你可以保持默认设置。然后,点击导入

一旦图像被导入,在画布上选择图像,然后点击顶部的 代码 按钮以生成我们需要的 Swift 代码:

图 9.18:在 Kite 中生成心脏的 Swift 代码

图 9.18:在 Kite 中生成心脏的 Swift 代码

就像我们在 “WE” 图像代码中所做的那样,我们只想复制以 pathtPath 命名开头的代码。在这个例子中,我们需要的代码从 let pathPath = CGMutablePath() 行开始,以 pathPath.move(to) 结束。

现在回到 Xcode - 创建一个新的 SwiftUI 视图文件,我将命名为 HeartView。在文件中,我们将创建一个结构体来放置代码,命名为 HeartShape,并使其符合 shape 协议,如下所示:

struct HeartShape: Shape {
    func path(in rect: CGRect) -> Path {
    }
 }

记得当我们制作 “WE” 形状时,我们使用了形状协议吗?像之前一样,它要求我们使用 path() 方法,我已经在这里添加了它。

现在,我们可以将 Kite 生成的 Swift 代码粘贴到这个方法中(再次,我将只包括前几行和最后几行,以免占用太多空间;如前所述,完整的代码文件和项目可在 GitHub 仓库中找到):

struct HeartShape: Shape {
  func path(in rect: CGRect) -> Path {
    let pathPath = CGMutablePath()
       pathPath.move(to: CGPoint(x: 245.632095, y: 
         460.368713))
       pathPath.addCurve(to: CGPoint(x: 221.824585, y: 
         420.429504), control1: CGPoint(x: 240.765762, y: 
         449.02652), control2: CGPoint(x: 231.057922, y: 
         432.740723))
........................................................
      pathPath.closeSubpath()
       pathPath.move(to: CGPoint(x: 245.632095, y: 
         460.368713))
    }
 }

接下来,让我们像之前一样重构代码。Command + 点击 pathPath 实例并选择 path。还将 path 常量的可变性从 let 替换为 var,并将 CGMutablePath() 替换为 Path()。这些更改应如下所示:

var path = Path()

最后一个要做的更改是在 HeartShape 结构体的底部;在代码的最后一行之后,我们需要返回创建的路径,因此请在 path 函数的末尾添加以下代码行:

return path

代码现在应该可以干净地构建,我们可以继续动画心脏周围的轮廓。这与 WEView 文件中的代码类似。在结构体的顶部,让我们添加以下变量:

//MARK: - VARIABLES
    @State var strokeReset: Bool = true
    @State var startStroke: CGFloat = 0.0
    @State var endStroke: CGFloat = 0.0

strokeReset 变量将跟踪动画,startStroke 将保存一个设置笔划起始 length 属性的值,而 endStroke 将保存一个设置笔划结束 length 属性的值。

进入结构体的 body 属性,让我们添加显示和动画心脏轮廓所需的视图:

var body: some View {
  ZStack {
    Group {
      //SHAPE OUTLINE
      HeartShape()
          .stroke(style: StrokeStyle(lineWidth: 0.5, 
            lineCap: .round, lineJoin: .round))
          .foregroundColor(.gray)
      //ANIMATING STROKE
      HeartShape()
          .trim(from: startStroke, to: endStroke)
          .stroke(style: StrokeStyle(lineWidth: 5, lineCap: 
            .round, lineJoin: .round))
          .foregroundColor(Color.white)
  }.offset (x: 75, y: -30)
  }.onAppear() {
    Timer.scheduledTimer(withTimeInterval: 0.23, repeats: 
      true) { timer in
        if (endStroke >= 1) {
          if (strokeReset) {
              Timer.scheduledTimer(withTimeInterval: 0.6, 
                repeats: false) { _ in
                  endStroke = 0
                  startStroke = 0
                  strokeReset.toggle()
              }
              strokeReset = false
          }
      }
      withAnimation(Animation.easeOut) {
          endStroke += 0.12
          startStroke = endStroke - 0.4 
      }
    }
  }
}

这里的代码几乎与 WEView 文件中的代码相同——不同的是笔划的颜色。在这里,它是白色的,我们将心脏偏移到屏幕的下方。

运行代码,你会看到有一个沿着心脏形状平滑动画的笔划。

再次,所有这些值都是供你探索和实验以创建所需外观的。

但让我们为这个形状做点不同的事情——让我们在动画的心脏形状内添加实际的位图图像。为此,在 Group 视图的底部添加以下代码:

Group {
      //SHAPE OUTLINE
      HeartShape()
          .stroke(style: StrokeStyle(lineWidth: 0.5, 
            lineCap: .round, lineJoin: .round))
          .foregroundColor(.gray)
      //ANIMATING STROKE
      HeartShape()
          .trim(from: startStroke, to: endStroke)
          .stroke(style: StrokeStyle(lineWidth: 5, lineCap: 
            .round, lineJoin: .round))
          .foregroundColor(Color.blue)
      ///HEART BITMAP IMAGE
Image("heart").resizable().aspectRatio(contentMode: 
        .fit)
          .frame(width: 246, alignment: .center)
          .position(x: 125, y:117.5)
     }

再次运行代码,你会看到它表现得和之前一样,但现在我们将心形位图图像放置在静止的笔画内,因此动画笔画正在追踪心形图像,呈现出有趣的外观:

图 9.19:围绕心形图像的笔画

图 9.19:围绕心形图像的笔画

我们现在已经完成了两个形状,即“WE”字母和心形。让我们继续并添加项目的最终图像,即 SwiftUI 标志。

在 SwiftUI 标志图像上创建笔画动画

现在我们已经到达了最终图像,我想通过让你自己完成这个任务来挑战你。使用我们在前两个形状中概述的步骤——包括移除背景、将你的图像矢量化以及将图像转换为 Swift 代码——并将代码复制到一个名为SwiftUILogoView的新 SwiftUI 视图文件中。

好的,试试看,准备好了就回来这里…

你做得怎么样?

在矢量化几个形状并收集代码之后,这个过程将变得非常熟悉且更快。如果你使用了除了我为这个项目提供的图像之外的其他图像,那完全没问题;只需知道,这些图像的 SwiftUI 代码将与我在此处展示的 SwiftUI 标志的代码不同,但动画代码将是相同的。

这是 Kite 为 SwiftUI 标志图像返回的 SwiftUI 代码(而且,正如之前一样,完整的代码文件可在 GitHub 仓库中找到):

struct SwiftUILogoShape: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 201.374207, y: 235.109955))
    path.addCurve(to: CGPoint(x: 231.023102, y: 
      220.544876), control1: CGPoint(x: 212.656006, y: 
      233.244553), control2: CGPoint(x: 222.89035, y: 
      228.216904))
   ........................................................
     path.addCurve(to: CGPoint(x: 207.854324, y: 
       191.140839), control1: CGPoint(x: 209.692215, y: 
       193.796051), control2: CGPoint(x: 208.764877, y: 
       192.663177))
     path.addLine(to: CGPoint(x: 207.854324, y: 
       191.140839))
     path.closeSubpath()
     path.move(to: CGPoint(x: 207.854324, y: 191.140839))
        return path
    }
}

我在“创建‘WE’图像上的笔画动画”部分之前已经解释了这段代码是如何创建形状对象的;如果你想复习一下这段代码的工作原理,请回到那个部分查看有关此代码的详细信息。

我们现在可以移动到文件顶部,在SwiftUILogo结构体内部,开始添加动画代码。我将添加这个最终形状的完整代码:

struct SwiftUILogoView: View {
    //MARK: - VARIABLES
    @State var strokeReset: Bool = true
    @State var startStroke: CGFloat = 0.0
    @State var endStroke: CGFloat = 0.0
    var body: some View {
      ZStack {
        Group {
          //SHAPE OUTLINE
              SwiftUILogoShape()
          .stroke(style: StrokeStyle(lineWidth: 0.5, 
            lineCap: .round, lineJoin: .round))
          .foregroundColor(.gray)
      //ANIMATING STROKE
      SwiftUILogoShape()
          .trim(from: startStroke, to: endStroke)
          .stroke(style: StrokeStyle(lineWidth: 5, lineCap: 
            .round, lineJoin: .round))
          .foregroundColor(Color.blue)
  }.offset(x: UIScreen.main.bounds.size.width / 5.5)
    }.onAppear() {
  Timer.scheduledTimer(withTimeInterval: 0.23, repeats: 
    true) { timer in
      if (endStroke >= 1) {
          if (strokeReset) {
              Timer.scheduledTimer(withTimeInterval: 0.6, 
                repeats: false) { _ in
                  endStroke = 0
                  startStroke = 0
                  strokeReset.toggle()
              }
              strokeReset = false
          }
      }
      withAnimation(Animation.easeOut) {
          endStroke += 0.12
          startStroke = endStroke - 0.4 
        }
      }
   }
  }
}

这段动画代码就是我们一直在添加的相同代码,如果你想了解它是如何工作的,请回到“WE”形状代码部分查看所有详细信息。这里的小差异是线条是蓝色的,并且我们在屏幕上将其偏移得更低。

运行这个程序并查看围绕标志的笔画。

项目的最后一部分是将所有三个形状结构体一起放到屏幕上。让我们接下来这么做。

组合动画笔画

对于这个,我们需要的代码非常少,因为我们只是在同一个地方调用了三个形状结构体。你只需要将以下代码添加到ContentView结构体中:

struct ContentView: View {
    var body: some View {
        VStack  {
            WeView()
            HeartView()
            SwiftUILogoView()
        }.background(Color.black)
    }
}

我们在这里所做的是在ContentView中调用了所有三个结构体,使用VStack垂直排列视图,然后在最后添加了一个黑色背景。当你运行代码时,你会看到以下结果:

图 9.20:完成的项目

图 9.20:完成的项目

现在,所有三个视图都以不同的颜色被动画化,呈现出轮廓。

摘要

干得好,完成了这个项目!为了创建最终的动画,我们学习了如何将位图图像转换为矢量文件,然后如何将这些矢量文件转换为我们可以使用 SwiftUI 进行工作的代码。此外,我们还学习了如何使用stroke修改器和计时器创建和动画化几乎任何形状的移动轮廓,并在本项目中使用了动画。

使用这个项目,你可以玩转这些值。你可以使笔触更粗或更细,让它移动得更快或更慢,还可以让它呈现出彩虹的任何颜色。

在你的应用中动画化笔触也有很多应用。它可以用于标志、文本和品牌,使它们脱颖而出并引起注意;它可以在游戏应用中使用,将用户的注意力吸引到屏幕的不同区域,或者在任何你想让应用生动起来的地方使用。这些用途仅限于你的想象力。

在下一章中,我们将学习如何以不同的方式动画化线条来创建波浪,然后将它们组合起来,通过一个动画浮标制作一个海洋,并配备音效。

第十章:创建海洋场景

在本章中,我们将创建一个海洋场景。为此,我们将回顾在之前的项目中用于创建波浪的 Shape 协议和 path 函数,以及一个新的 SwiftUI 属性 animatableData,它将帮助我们使曲线动画以流畅的波浪状运动。

我们还将向场景中添加一个浮标的图像,并以多种不同的方式对其动画化,包括沿着锚点移动它,围绕这个点的中心旋转它,以及使其在 y-轴上上下移动——这样浮标看起来就像在水面上起伏。

最后,我们将向项目中添加一些音效,以帮助它真正地活跃起来。

在本章中,我们将涵盖以下主题:

  • 添加波浪的偏移属性

  • 使用 Shape 协议和 Path 函数创建波浪形状

  • 添加波浪的 animatableData 属性

  • 设置动画的 ContentView

  • ContentView 中复制并动画化波浪形状

  • 向海洋场景添加一个动画浮标

  • 添加音效

技术要求

您可以从 GitHub 上的 Chapter 10 文件夹下载资源和完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

添加波浪的偏移属性

好的,让我们先创建一个新的 SwiftUI 项目——我将称之为 Making Waves

然后,让我们添加一个新的文件,该文件将负责为海洋制作波浪。按 Command + N 创建一个新文件,然后选择 WaveView

在这个新文件中,我们将对结构体进行一些小的修改,使其符合 Shape 协议,就像我们在之前的项目中做的那样。因此,在 WaveView 结构体的顶部,在其名称之后,移除 View 协议并将其替换为 Shape 协议。此外,移除 body 属性,因为我们在这里不需要它。文件应该看起来像这样:

struct WaveView: Shape {
}

我们移除 body 属性的原因是它被用来返回一个视图;然而,我们将返回一个 path 变量,该变量将保存我们想要动画化的波浪形状。

我们想要创建的动画将是一条波浪线,它在 y 轴上上下波动,呈波浪状运动。我们需要一个变量来控制这个动画运动,所以让我们在 WaveView 结构体内部添加一个变量来处理这个:

var yOffset: CGFloat = 0.0

它被称为 yOffset,因为这个变量将执行的操作:它只会在 y 轴上偏移线条。它的初始值设置为 0.0。它的类型设置为 CGFloat

我们还需要另一个特殊的内置 Swift 变量,它将动态地改变 yOffset 变量的值并使事物动画化,称为 animatableData,但在添加它之前,让我们首先创建波浪形状本身。

使用 Shape 协议和 Path 函数创建波浪形状

对于波形形状,这很简单——我们只需要画三条直线和一条曲线,并将它们连接起来。想象成一个矩形,矩形的顶部线是曲线波形部分(跳转到 图 10**.4 来看看我的意思)。

在我们开始添加波形代码之前,让我们首先修改 Previews 结构体,以便我们可以看到我们添加的每一行代码的结果。修改代码,使其看起来如下所示:

struct WaveView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
        WaveView(yOffset: 0.7)
            .stroke(Color.blue, lineWidth: 3)
            .frame(height: 250)
        .padding()
            .previewDisplayName("Wave")

        }
    }
}

我们首先在 Previews 结构体的 yOffset 参数中设置一个值为 0.7。这是一个简单的硬编码值,允许我们在 Xcode 右侧的预览中显示我们的波形形状。yOffset 通过偏移贝塞尔曲线中的两个控制点来工作——一个控制点将在曲线线上向上偏移,另一个控制点将在曲线线上向下偏移。下一行代码为波形形状添加了描边,以便我们可以在预览中看到它,并给波形一个 250 点的高度。

现在,随着预览中的这些更改,我们添加到 WaveView 结构体中的任何代码都将立即在预览中可见。让我们现在添加波形形状代码,一次添加一小段代码,这样我们可以更好地理解波形形状是如何形成的:

func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: rect.minX, 
          y: rect.maxY))
        return path
    }

我们首先要做的是创建 path 实例;这是我们将要加载所有构成波形线条的变量。

下一行调用 move_to 方法。这是开始绘制你正在制作的形状的方法;在这个项目中,那就是波形形状。to 参数询问你希望在屏幕上的哪个位置添加第一个点。对于该参数,我们传递 .zero 的值,这是将 xy 轴值设置为 0 的另一种方式。(我们也可以简单地写出值作为 X: 0Y: 0,但使用 .zero 更容易。)

下一行代码调用 addLine 函数,它只是简单地绘制一条直线。它的参数需要一个 X 和 Y 值来知道在哪里放置下一个点以绘制线条。对于这些 X 和 Y 值,我们传递两个辅助函数 minXmaxY,它们会自动为我们获取屏幕上的不同点。

iPhone 使用 X 和 Y 坐标系统——X 从左到右运行,Y 从上到下运行。iPhone 屏幕的左上角将具有 X 和 Y 值为 0, 0。当你向屏幕右侧移动时,X 值增加,当你向屏幕下方移动时,Y 值增加。因此,通过在 addLine 参数中使用 minX 函数,它将在屏幕的远左端放置一个点(X 坐标平面上的最小位置)。我们也可以使用硬编码的值,例如 50 或 100,但通过使用 minX 函数,它告诉 Xcode 将点放置在屏幕的远左端。虽然这可能看起来有些模糊,但在考虑到苹果设备具有不同的屏幕尺寸时,这很有帮助。

注意

以下是在屏幕上放置点而不进行硬编码时可以使用的 Swift 辅助函数:minXmidXmaxXminYmidYmaxY。正如你可能想象的那样,midXmidY函数将点添加到屏幕的中间区域,而maxXmaxY,当一起使用时,将点添加到屏幕的右下角区域。

现在,该代码的结果在预览窗口中可见,显示一条垂直的直线:

图 10.1:第一行

图 10.1:第一行

move_toaddLine函数已经从屏幕的中间左区域绘制了一条线到屏幕的左下区域。线的长度是由我们在Previews结构体中使用的frame修改器设置的。通过在frame修改器的参数中添加更大的值,你可以使线变得更长。

现在,波形的第一行已经完成,让我们绘制第二行——这将是一条从第一行的底部开始并向右延伸到屏幕的横线。我们可以通过将高亮显示的线添加到现有代码中来实现这一点:

func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: rect.minX,
          y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX,
          y: rect.maxY))
        return path
    }

这行代码再次调用了addLine函数,向maxXmaxY位置添加了一个点。现在结果应该看起来像这样:

图 10.2:第二行

图 10.2:第二行

现在已经完成了两条线和波形的一半,让我们现在添加第三条线,这是最后一条直线。将以下高亮显示的行添加到你的现有代码中:

func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: rect.minX,
          y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX,
          y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX,
          y: rect.minY))
        return path
    }

这行代码向屏幕的maxX(最右侧)和minY(最左侧)部分添加了一个点,因此结果将看起来像这样:

图 10.3:第三行

图 10.3:第三行

现在我们已经有了三条直线,我们只需要在顶部添加一条波浪线来完成波形。现在让我们添加它:

func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: rect.minX,
          y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX,
          y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX,
          y: rect.minY))
        path.addCurve(to: CGPoint(x: rect.minX,
          y: rect.minY),
control1: CGPoint(x: rect.maxX * 0.45, 
          y: rect.minY - (rect.maxY * yOffset)),
        control2: CGPoint(x: rect.maxX * 0.45,
          y: (rect.maxY * yOffset)))
        return path
    }

在这里,我们使用了addCurve函数来创建贝塞尔曲线。这个函数有三个参数。

第一个参数to是用来在屏幕上添加一个点以绘制曲线的。上一行代码绘制了一条以矩形右上角为终点的线,因此从那里开始,addCurve函数将绘制到minXminY位置,即矩形的左上角,从而有效地封闭了形状。

接下来的两个参数被称为control1control2点,具体来说。这些参数在这里是为了接受将调整与y轴相关的线条曲率的值——control1将调整线的右侧部分,而control2将调整线的左侧部分。

要了解这些控制点是如何工作的,如果我们向这两个参数传递一个 0 的值,那么创建的线条将是直的,而不是曲线;这是因为 0 的值不会改变这些点的上下位置,因此线条保持直线。但是通过传递浮点值,控制点开始根据给定的量在右侧和左侧弯曲线条。控制点不对线条的右侧和左侧端点起作用;那些是关闭波形形状的连接点。相反,控制点是均匀分布的,并从端点偏移。

现在看看用于控制点的值,它们已经被计算出来,以创建一条右侧向上弯曲、左侧向下弯曲的线条。control1control2x 值通过将 maxX 值乘以 0.45(这是一个硬编码的值,有助于在 x 轴上塑造线条的弯曲)来获得结果。

另一方面,control1control2y 值的计算方式略有不同。control1 通过从 minY 值中减去 maxX 乘以 yOffset 变量的结果来获得其值。control2 通过将 maxY 值乘以 yOffset 值来获得其值。

这些计算一开始可能有些不清楚,但当你完成项目并尝试不同的参数值时,你会看到数学是如何工作的,使得线条的弯曲移动;移动的量直接与 yOffset 变量的值的大小或小有关。

现在我们来看看添加控制点后的结果:

图 10.4:完成的波形形状

图 10.4:完成的波形形状

现在,完成了四条线,我们已经创建了波形。接下来,我们将使用一个名为 animatableData 的特殊 Swift 属性使形状变得生动起来。

注意

当使用 path_in 函数时,正如我们在这里所做的那样,还有一个用于关闭我们正在绘制的形状的函数:closeSubpath()。当不需要使用 addLine() 函数并传入 X 和 Y 点来关闭形状的末端时,这很有用。我们在这里不需要使用它,因为我们绘制了波形,并以顶部的曲线线结束。如果我先画曲线线,然后想要以直线结束,那么我可以使用这个函数来节省一些代码。

添加波形的 animatableData 属性

现在我们有一个波形形状,我们可以根据需要多次复制它来创建分层海洋场景。然而,这些波浪仍然是静态的,这意味着除非我们有改变 yOffset 变量值的方法,否则它们不会做任何事情。为了实现这一点,我们需要在此文件中添加一个额外的变量:animatableData

因此,让我们在 yOffset 变量的声明下方添加它:

var animatableData: CGFloat {
        get {
            return yOffset
        }
        set {
            yOffset = newValue
        }
    }

animatableData 属性是名为 Animatable 的 Swift 类的实例,并且是一个具有附加获取器和设置器的内置 Swift 属性。具有获取器和设置器的属性正是这样做的:获取一个值并设置一个值。

使用 animatableData 属性与仅使用简单的 @State 属性进行动画相比,提供了一些好处:

  • 它允许你声明性地指定一个视图可以进行动画,使其他开发者(以及框架)清楚地知道该视图旨在进行动画。

  • 它允许框架自动在旧值和新值之间进行插值,而不是要求你手动计算中间值。

  • 它允许你使用 animation() 方法来指定动画应该如何执行,例如持续时间、曲线和延迟。

  • 它允许你使用更高级的动画技术,如 animation(_:)transition(_:),来指定视图不同状态之间的动画。

在我们的项目中,我们使用 animatableData 属性来获取和设置波形控制点的值。这种获取和设置是持续进行的,因此也会使 yOffset 变量的值不断变化。由于 yOffset 变量用于在 path_in() 函数内部创建曲线,因此曲线也会动态变化。

animatableData 属性就位,并且波形形状完成后,我们可以继续到 ContentView,在那里我们将动画添加到形状中。

为动画设置 ContentView

在这里,在 ContentView 中,我们准备使用波形形状并添加动画。让我们从在文件底部创建一个新的结构体开始,位于 Previews 结构体之上,并将其命名为 WaveCreation

struct WaveCreation: View {
    var body: some View { 
    }
}

此结构体与 ContentView 结构体非常相似——它遵循 View 协议,这意味着它必须实现 body 属性。body 属性本身将返回一个视图,那将是动画波形。到下一节结束时,我们将总共创建六个波形,整齐地分布在栈内。

我们需要几个变量来实现这一点,一些用于动画,一些允许我们改变波形曲线的大小。在 WaveCreation 结构体内部,在 body 属性上方添加以下代码:

@Binding var animateOffset: Bool
    var curveOne: CGFloat
    var curveTwo: CGFloat
    var radius: CGFloat
    var shadowX: CGFloat
    var shadowY: CGFloat
    var duration: Double

这里共有七个变量:

  • 第一个是一个 Binding 变量,我们将在 ContentView 中使用它,当它设置为 true 时开始动画。

  • 接下来的两个变量被称为curveOnecurveTwo,它们将被用来设置波浪的两个曲线的大小。记住波浪线形状上有两个曲线,它们是通过这两个控制点创建的,一个用于波浪线的左侧曲线,另一个用于波浪线的右侧曲线。curveOnecurveTwo变量将允许我们改变波浪线上这些曲线的大小;更大的值将给我们更大的曲线,因此更大的波浪。

  • 接下来是radius变量,它允许我们设置阴影的半径。我打算稍后给每个波添加一个阴影,因此这个变量使得这种定制变得更加容易。

  • 这些阴影的位置也很重要;我们可以通过两个变量shadowXshadowY来控制这种位置。

  • 最后,duration变量允许我们设置每个波浪动画的持续时间。

在这些变量就位后,我们只需要几行代码就可以完成WaveCreation结构体的创建。这些行如下:

var body: some View {
    WaveView(yOffset: animateOffset ? curveOne : curveTwo)
        .fill(Color(UIColor.blue))
        .shadow(color: .gray, radius: radius, x: shadowX,
          y: shadowY)
        .animation(Animation.easeInOut(duration:
          duration).repeatForever(autoreverses: true),
          value:animateOffset)
    }

代码的第一行调用了WaveView结构体,这需要为其yOffset参数提供一个值。我们传递两个值之一:当animateOffset变量为true时,我们将使用curveOne变量,这意味着波浪线上的一个曲线将在y轴上动画化。但是当animateOffset变量为false时,我们将使用curveTwo变量,这将控制波浪线的另一侧在y轴上的位置。

下一行代码使用shadow修饰符给波浪添加阴影。查看参数,color参数获取灰色颜色(与蓝色波浪搭配看起来很好),radius参数设置阴影的大小,X 和 Y 参数允许我们调整阴影的上、下、左、右位置。

然后,最后一行添加了动画——这是一个easeInOut时间曲线,这意味着动画将缓慢开始,然后逐渐增强,当它结束时,它会缓慢地结束。动画的持续时间将通过duration变量设置,repeatForeverautoreverses设置为true。然后,对于value参数,它获取animateOffset变量以开始动画。

现在,我们场景中不再只有一个波浪,我们将在ContentView中调用这个结构体六次来制作海洋。

ContentView中复制并动画化波浪形状

当创建我们的海洋时,我们将在场景中堆叠六个波浪,并错开动画值,使它们在不同的时间动画化。现在在ContentView文件中工作,我们首先在ContentView结构体中添加一个变量来切换动画:

@State private var animateOffset = false

接下来,让我们添加一个ZStack来容纳所有波浪,并在其中设置背景为蓝色天空颜色:

 ZStack {
            //MARK: - BACKGROUND
            Color.blue
                 .opacity(0.5)
                 .edgesIgnoringSafeArea(.all)
        }

这段代码为背景添加了不透明度为0.5(50%)的blue颜色,然后我们使用edgesIgnoreSafeArea修饰符将背景拉伸到屏幕的边缘。

现在,我们准备添加第一个波浪。因此,将以下代码直接添加到edgesIgnoring修饰符下方,在ZStack内部:

//MARK: - WAVE 1
    WaveCreation(animateOffset: $animateOffset, curveOne:
      0.05, curveTwo: -0.05, radius: 50, shadowX: 0,
      shadowY: 50, duration: 5.0)
        .opacity(0.8)
        .offset(y: 575)

添加波浪非常简单,因为我们已经完成了所有工作——在这里,我们只需调用WaveCreation结构体并填充一些值到其参数中。

第一个值是用于animateOffset参数的,这是一个布尔值,用于启动动画。

然后,curveOnecurveTwo参数需要为我们在WaveView中制作的波浪线的两部分提供值。当你增加曲线变量的值时,你增加了一侧波浪的高度。如果你增加两个曲线变量的值,你增加了波浪两侧的高度。相反,如果你通过使用负数来减少曲线变量的值,那么波浪就会变小。

我在这里使用了一个小的值.05-.05。这是因为这是场景中的第一个波浪,它将更远地延伸到海中,所以我希望这个波浪比靠近岸边(和用户)的波浪稍微平静一些。

下一个参数是用于阴影半径的。它设置为50,这意味着它创建了一个大小为 50 个点的阴影。阴影的方向将由shadowXshadowY属性控制——shadowX的值为0,因为我不想在x轴上左右移动阴影,但我确实想在y轴上调整它,shadowY变量的50个点值将使阴影向上移动 50 个点。

然后,我们有动画的duration。我们将其设置为5秒,以创建我们希望通过这个第一个波浪实现的较慢、较平静的动画。

最后,我们将这个波浪的不透明度设置为.8,使其比我们即将添加的其他波浪稍微不透明一些。然后,我们使用y轴上的575个点值来偏移这个波浪,将其放置在屏幕底部。

这样就完成了我们的第一个波浪。要查看这个动画,我们只需在onAppear修饰符内切换animateOffset变量。将此代码直接添加到ZStack的末尾:

  .onAppear() {
            animateOffset.toggle()
        }

在代码就绪后,我们现在可以运行应用并查看结果:

图 10.5:我们的第一个波浪

图 10.5:我们的第一个波浪

第一个波浪有一个平滑的来回动画,我们现在准备好在上面构建我们的下一个波浪。

其他波浪的代码几乎相同,只是每个参数中设置的特定值不同。以下是下一个五个波浪的代码:

//MARK: - WAVE 2
    WaveCreation(animateOffset: $animateOffset, 
      curveOne: -0.07, curveTwo: 0.07, radius: 100,
      shadowX: 0, shadowY: 10, duration: 4.0)
.offset(y: 610)
//MARK: - WAVE 3
    WaveCreation(animateOffset: $animateOffset, curveOne: 0.1,       curveTwo: -0.1, radius: 30, shadowX: 0, shadowY: 0,       duration: 3.7)
.offset(y: 645)
//MARK: - WAVE 4
    WaveCreation(animateOffset: $animateOffset, 
      curveOne: 0.14, curveTwo: -0.1, radius: 70, 
      shadowX: 0, shadowY: 10, duration: 3.5)
.offset(y: 705)
//MARK: - WAVE 5
    WaveCreation(animateOffset: $animateOffset, 
      curveOne: -0.05, curveTwo: 0.08, radius: 60, 
      shadowX: 0, shadowY: 20, duration: 3.2)
        .opacity(0.8)
.offset(y: 740)
//MARK: - WAVE 6
    WaveCreation(animateOffset: $animateOffset, 
      curveOne: -0.05, curveTwo: 0.08, radius: 60, 
      shadowX: 0, shadowY: 20, duration: 3.4)
.offset(y: 800)

如您所见,这里的值与第一波的值不同——例如,波浪 2 的曲线变量值更大,半径也更大;然而,这个波浪的持续时间略短,值为4秒,使得动画稍微快一些。波浪 3、4、5 和 6 也有更短的持续时间,因此,当我们接近用户的视角时,我们正在增加每个波浪的动画速度。

阴影也各不相同,为波浪增添了一抹漂亮的白色浪尖效果,并有助于区分波浪彼此之间的界限。我使用灰色来表示阴影,因为它不是太突出;然而,如果你更喜欢看起来更亮的波浪,可以尝试使用白色。

现在,请在模拟器中再次运行项目,并查看我们海洋场景中来回动画的所有波浪:

图 10.6:所有六个波浪一起

图 10.6:所有六个波浪一起

与第一波一样,动画平滑且富有节奏感,通过层层叠加波浪并将每个波浪略微向下偏移,我们创建了一个海洋场景。

接下来,我们将继续向场景中添加动画,通过添加一个在水中上下起伏的浮标来实现,浮标上还配有闪烁的灯光。

向海洋场景添加动画浮标

当我们将浮标添加到我们的海洋场景中时,我们将给它四个不同的动画。它将执行以下操作:

  • 在顶部有一个闪烁的灯光

  • 前后倾斜

  • 上下移动

  • 沿其前导锚点旋转

所有这些动画将结合在一起,创建一个逼真的起伏效果,模拟一个在海洋中漂浮的物体对波浪和洋流的反应。

首先,下载buoy图像,您可以在 GitHub 仓库中的第十章文件夹中找到它,并将图像添加到资产目录中。然后,创建一个新的 SwiftUI 视图文件,我们将称之为BuoyView。在文件中,我们需要六个变量来使这个浮标离开地面并进入水中,所以请将以下代码添加到BuoyView结构体中:

@Binding var tiltForwardBackward: Bool
    @Binding var upAndDown: Bool
    @Binding var leadingAnchorAnimate: Bool
    @State private var red = 1.0
    @State private var green = 1.0
    @State private var blue = 1.0

在这里,我们使用了三个布尔绑定变量,它们将监督各自的动画:tiltForwardBackwardupAndDownleadingAnchorAnimate。然后,使用三个State变量来制作闪烁灯光动画。

我们刚刚向BuoyView结构体中添加了一些绑定变量,这将在项目中引入一些错误。这些错误发生的原因是,每次我们向正在工作的结构体中添加绑定变量时,我们还需要在Preview结构体中添加它们;否则,预览将抱怨。Preview结构体的任务是显示文件中编写的所有代码,因此它与BuoyView结构体协同工作。

因此,修改 Preview 结构代码,使其看起来如下所示:

struct BuoyView_Previews: PreviewProvider {
    static var previews: some View {
        BuoyView(tiltForwardBackward: .constant(true),
        upAndDown: .constant(true), leadingAnchorAnimate:
        .constant(true))
    }
}

现在,我们再次实现了无错误。

现在,让我们进入body属性,并添加一个ZStack,然后在ZStack内部添加浮标图像:

 ZStack {
           Image("buoy")
        }

现在预览中应该可以看到浮标:

图 10.7:浮标

图 10.7:浮标

我们将首先处理闪烁的灯光。为了实现这个效果,我们必须添加一个矩形形状,将其放置在浮标顶部(在弯曲的顶部内部),然后给它一些颜色,最后切换颜色以使其看起来像灯光在闪烁。

制作浮标灯光闪烁

为了开始创建灯光的过程,让我们在浮标图像上叠加一个矩形。将以下代码添加到Image初始化器下方以实现此操作:

 ZStack {
           Image("buoy").overlay(Rectangle())
        }

这是overlay修饰符,它允许我们在现有视图上添加新视图,以创建视图层。

如果你查看预览,你会看到我们刚刚添加的矩形对于我们的需求来说太大。因此,需要对它进行一些更多的样式化和调整大小。但在我们继续之前,让我们在onAppear修饰符中将颜色变量设置为一些初始值,这样我们就可以在预览中看到我们的工作进展。将以下代码添加到ZStack的末尾:

.onAppear() {
            red = 0.5
            green = 0.5
            blue = 0.5
        }

我将redgreenblue变量设置为0.5(50%)。这些 RGB 颜色 50%的等量组合产生灰色或中性颜色,这将与蓝色天空背景后面的闪烁效果很好。

接下来,回到Image初始化器,让我们使用另一个overlay修饰符和颜色变量将中性灰色添加到矩形中。请注意,将新代码直接放置在覆盖的闭合括号之前,如下所示:

 ZStack {
           Image("buoy").overlay(Rectangle()
                .overlay(Color(red: red,green: green,blue:
                  blue)))
        }

现在,我们有一个大灰色的矩形,但我们看不到浮标了,因为矩形太大,需要调整大小。我们将解决这个问题。添加以下矩形修饰符,这将调整矩形的大小和位置,并将它们直接放置在第一个overlay修饰符的闭合括号内:

 Image("buoy").overlay(Rectangle()
     .overlay(Color(red: red,green: green,blue: blue))
     //adds a corner radius only to the bottom corners of
       the rectangle
                .frame(width: 12, height: 17)
                .position(x: 112.5, y: 19.5))
                 }

frame修饰符将矩形的高度和宽度设置为12乘以17的小尺寸。然后,position修饰符将矩形放置在 X 和 Y 坐标112.519.5处,这是浮标的顶部部分,代表灯光的区域。

你可以在以下图像中看到矩形:

图 10.8:带有矩形覆盖的浮标

图 10.8:带有矩形覆盖的浮标

矩形看起来不错;然而,一个矩形,嗯,就是一个矩形。它的四个边角都很尖锐,而浮标的顶部有圆角,正如你在图 10.9中可以看到的:

图 10.9:矩形覆盖的近距离观察

图 10.9:矩形覆盖的近距离观察

使用我们当前的矩形有点像把方钉塞进圆孔。幸运的是,SwiftUI 确实给了我们一个修饰符,可以圆角矩形的角落半径,但不幸的是,它仍然存在一个问题:它将所有四个角落都圆角了,而我们只想对顶部两个角落进行圆角处理。

我们可以通过添加一个扩展到圆角修饰符并改变其行为,使其只对两个角起作用,通过编写多行代码来解决这个问题。然而,通过以独特的方式使用padding修饰符,我们可以以更简单的方式实现两个角的圆角修饰符。

要这样做,在BuoyView结构体中的最后一个变量之后添加一个常量来存储我们想要用于圆角的两角半径:

    let cRadius = 8.0

我把这个常量命名为cRadius,代表圆角半径,并将其设置为 8.0 点。你使圆角半径值越大,矩形就越圆;就我们的目的而言,8 点的值给矩形的两个顶部角添加了足够的圆角,使其完美地适合浮标灯。

在第二个overlay修饰符之后直接添加以下代码:

Image("buoy").overlay(Rectangle()
    .overlay(Color(red: red,green: green,blue: blue))
    ///add a corner radius only to the bottom corners
        .padding(.bottom, cRadius)
        .cornerRadius(cRadius)
        .padding(.bottom, -cRadius)
    .frame(width: 12, height: 17)
    .position(x: 112.5, y: 19.5))

下面是代码的作用。.padding(.bottom, cRadius)这一行给矩形的底部添加了 8 点的填充。然后,我们调用cornerRadius修饰符,这将在矩形的四个角上放置圆角。但由于矩形的底部有 8 点的填充,所以我们看不到底部放置的圆角;我们只会看到矩形顶部的圆角,这正是我们想要的。

最后,我们再次调用padding修饰符,并再次选择.bottom选项,只在矩形的底部放置填充。然而,这次我们设置了一个-8 点的值。当我们使这个选项为负值时,它实际上将矩形向下延伸在y轴上——8 点——但保留了我们在底部想要的两个尖锐角。这是一个相当巧妙的技巧,节省了我们为cornerRadius修饰符编写代码的时间。这就是我们现在预览中看到的结果:

图 10.10:矩形覆盖现在适合浮标的弯曲尖端

图 10.10:矩形覆盖现在适合浮标的弯曲尖端

预览显示,我们的灰色矩形现在与灯的顶部部分曲线相匹配,并且有两个尖锐的角来匹配灯的底部部分。

要完成灯光并使其闪烁,我们只需要在ZStack中的最后一行代码之后添加一行代码:

    ///the animation for the blinking light
    .animation(Animation.easeOut(duration:
      1).repeatForever(autoreverses: true),value: red)

BuoyView文件中运行预览,现在你会看到我们有一个闪烁的灯,它会持续 1 秒的闪烁,然后重复进行,或者直到应用程序停止:

图 10.11:带有闪烁灯的完成浮标

图 10.11:带有闪烁灯的完成浮标

现在我们来添加使浮标移动的动画。

使浮标移动

我们将添加的第一个动画是使浮标沿着其前导锚点旋转。为此,在ZStack中的上一行代码之后添加以下代码:

   ///the animation for the anchor point motion
    .rotationEffect(.degrees(leadingAnchorAnimate ? 7 :
      -3), anchor: .leading) 
    .animation(Animation.easeOut(duration:
      0.9).repeatForever(autoreverses: true),
      value: leadingAnchorAnimate)

此代码使用rotationEffect修饰符,当leadingAnchorAnimate属性变为true时,将浮标旋转7度,当它为false时,旋转-3度。对于anchor参数,我们使用了leading锚点选项,这会使图像围绕前边缘旋转,但您也可以使用bottom选项以获得不同的旋转效果。

然后,我们添加了animation修饰符,将其duration设置为0.9秒,并设置为repeatForeverautoreverses设置为true

我们还将在onAppear修饰符中将leadingAnchorAnimate属性切换为true,但首先,让我们将其他两个动画添加到浮标上。

接下来要添加的动画将使浮标前后倾斜。在上一行代码下方添加此代码:

   ///the animation for the tilt forward and backward
     motion
    .rotationEffect(.degrees(tiltForwardBackward ? -20 :
      15))
    .animation(Animation.easeInOut(duration:
      1.0).delay(0.2).repeatForever(autoreverses:
      true),value: tiltForwardBackward)

这几乎与之前的代码相同,但.rotationEffect的值现在设置为tiltForwardBackward。旋转或前后倾斜的量将是-20度或15度,具体取决于tiltForwardBackward变量中的值。此外,在animation修饰符中,我们添加了轻微的延迟0.2,这将有助于添加更逼真的摆动动作。

现在,为了给浮标添加最后的动画,使其上下移动,请在上一行代码之后直接添加以下代码:

.offset(y: upAndDown ? -10 : 10)

负值将使图像沿y轴向上移动,正值将使图像沿y轴向下移动。因此,此代码将根据upAndDown变量将整个浮标图像向上或向下移动 10 点。

BuoyView文件中,我们需要的最后一部分代码是在onAppear修饰符中切换这三个动画的代码。所以,添加以下代码:

.onAppear() {
           leadingAnchorAnimate.toggle()
             tiltForwardBackward.toggle()
             upAndDown.toggle()
            red = 0.5
            green = 0.5
            blue = 0.5
        }

这就完成了BuoyView文件,现在我们可以继续到ContentView并添加浮标到场景中。

将浮标添加到场景中

我们希望将浮标放置在我们海洋场景的稍远位置,所以让我们在波 1 之后立即添加它。在第一个波浪的闭合花括号之后添加以下代码:

  //MARK: - BUOY
      BuoyView(tiltForwardBackward: $tiltForwardBackward,
        upAndDown: $upAndDown, leadingAnchorAnimate:
        $leadingAnchorAnimnate)
      .offset(y: 205)

BuoyView初始化器内部,我们传递了三个绑定属性,这些属性启动了三个不同的动画。在这些绑定属性之前添加一个美元符号,这告诉编译器BuoyView结构和ContentView结构之间存在双向绑定。接下来,使用offset修饰符,我们可以将浮标放置在海洋场景的正确高度。

现在,请在模拟器中运行项目以查看动画:

图 10.12:完成的动画

图 10.12:完成的动画

虽然波浪和浮标的代码可以独立工作,但这两个元素在视觉上协同工作,共同创建我们的海洋场景。

我们接下来可以添加的最后一个元素是一些音效。如何?在背景中添加一些海浪声和浮标铃铛声?这将真正完成项目。

添加音效

我们已经在第四章中添加了声音,当时我们在构建唱片机项目时,这并没有什么复杂之处。

首先,创建一个 Swift 文件(只是一个 Swift 文件,而不是 SwiftUI View 文件),并将其命名为PlaySound。然后,将名为buoyBells的 M4A 声音效果文件(你可以在 GitHub 上找到)拖放到文件导航器中。

PlaySound文件中,我们首先需要做的是导入 AVFoundation 框架:

import AVFoundation

AVFoundation 框架为我们提供了添加音频到项目所需的所有类和方法。

接下来,创建一个audioPlayer对象来播放我们的声音文件:

var audioPlayer: AVAudioPlayer?

这个audioPlayer对象是可选的,你可以通过末尾的问号来判断。它需要是可选的,因为声音文件可能因为任何原因不存在——比如文件损坏,或者声音文件已经从服务器下载并且网络超时了——这样就可以保护项目不会崩溃。

最后,在上一行代码下面,我们只需要添加一个函数来处理这个项目的音频需求:

func playSound(name: String, type: String) {
    if let path = Bundle.main.path(forResource: sound,
      ofType: type) {
        do {
            audioPlayer = try AVAudioPlayer(contentsOf:
              URL(fileURLWithPath: path))
            audioPlayer?.play()
        } catch {
            print("Could not find and play the sound file")
        }
    }
}

这个函数被命名为playSound。它接受两个字符串,一个用于存储文件的名称,另一个用于存储文件的扩展类型。

我们首先尝试通过使用path_forResource()函数访问应用包中的声音文件。应用包是一个为每个应用创建的内部隐藏文件夹,用于存放启动和运行应用所需的所有必要文件。

如果存在具有指定名称和类型的文件,则代码将进入do块,并尝试使用存储在path常量中的文件位置路径创建音频播放器。如果因为任何原因找不到文件或文件损坏,则代码将进入catch块并打印出错误。

对于PlaySound文件来说,这就真的结束了。让我们回到ContentView,并将声音效果添加到项目中。在onAppear修饰符中,使用以下代码设置声音开始播放:

.onAppear() {
            animateOffset.toggle()
            playSound(name: "buoyBells", type: "m4a")
        }

当应用在设备上停止运行或用户关闭应用时,我们还需要一种停止声音的方法。使声音停止的方法是调用onDisappear修饰符,我们可以在onAppear修饰符的闭合花括号上直接使用它,如下所示:

       .onDisappear()
        {
            audioPlayer?.stop()
        }

就这样,项目就完成了!

摘要

在这一章中,我们创建了一个海洋场景,其中包括移动的波浪和带有闪烁灯光的漂浮浮标,还添加了一些声音。

更具体地说,你使用了 Shape 协议、path函数、animatableData属性和曲线变量来使波浪形状栩栩如生。当我们在浮标上工作时,我们研究了如何组合多种类型的动画来创建独特的效果,以及如何打开和关闭不同的颜色来创建闪烁效果。

本项目的参数高度可定制,所以请随意进一步实验——也许你想创建更大的波浪或更多波浪,以横向方向创建波浪,调整阴影以创建地平线,或者甚至用船替换浮标!

让我们继续前进。在下一个项目中,我们将使用计时器和音效创建一个工作电梯动画。

第十一章:动画电梯

欢迎来到电梯项目!在这个项目中,我们将创建一个带有声音、楼层灯光、按钮,甚至内部人员图像的工作电梯。我们将使用从 Swift timer 类中可用的计时器来控制电梯,就像我们在之前的项目中做的那样。

为了将这些元素组合在一起,我们将使用 @ObservableObject 协议创建一个数据模型。苹果建议将数据模型作为存储和处理应用程序使用的数据的地方。数据模型也与应用程序的用户界面分开,其中创建视图。保持数据和 UI 分开的原因是这种范式促进了模块化和可测试性。当数据不与 UI 代码混合时,更容易找到逻辑中的错误。一旦我们设置了数据模型,我们就可以使用发布包装器在应用程序的任何地方发布该数据,就像我们稍后将要看到的那样。

在本章中,我们将涵盖以下主题:

  • 设置项目和添加 Binding 变量

  • 使用图片和 GeometryReader 视图组装电梯

  • 将人员放入电梯内

  • 创建数据模型并使用 @ObservableObject 协议

  • 添加数据模型函数

  • 添加背景、按钮和动画门

  • 添加楼层指示灯

技术要求

您可以在 GitHub 上下载资源和完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

设置项目和添加 Binding 变量

好的,让我们开始吧!像往常一样,我们将创建一个新的 Xcode 项目(我将我的项目命名为 Elevator)。

接下来,在 GitHub 仓库中,将 Chapter 11 文件夹中的所有图片拖放到资产目录中。这些图片包括 doorDrameleftDoorrightDoorinsidemanman2man3man4

然后,将音频文件 – doorsOpenCloseelevatorChime – 放入项目导航器。

接下来,我们需要一个新的文件,我们可以在这个文件中组装电梯并添加人员,因此创建一个新的 SwiftUI 视图文件,并将其命名为 ElevatorAndPeopleView。我们在这个文件中只需要一个变量,它将是绑定变量。让我们在 ElevatorAndPeopleView 结构体顶部添加它:

    @Binding var doorsOpened: Bool

这个变量将控制电梯门的开启和关闭。

让我们更新 Previews,以便代码可以无错误地构建。在文件底部,修改 Previews 结构体,使其看起来如下:

struct ElevatorAndPeopleView_Previews: PreviewProvider {
    static var previews: some View {
        ElevatorAndPeopleView(doorsOpened: 
          .constant(false), moveMouth: .constant(false))
    } 

代码将 false 的值添加到两个绑定属性中,项目再次干净地构建。

我们现在可以继续构建电梯。

使用图片和 GeometryReader 视图组装电梯

下一步是使用资产目录中的一些图片来制作一个电梯。

我们将首先添加电梯的内部部分,这将在 body 属性内部添加以下代码来完成:

 ZStack {
 GeometryReader { geo in
                }
        }

首先,我们有 ZStack 来容纳所有后续视图,以及一个 GeometryReader 视图。

GeometryReader 视图是一个容器视图,它将其内容定义为它自己的大小和坐标空间的一个函数。它有点像我们使用的其他容器,例如 VStackHStack,但不同之处在于 GeometryReader 由于其代理参数 geo 常量(你可以将其命名为任何你想要的)而具有更多的灵活性。

此代理将包含有关容器大小和坐标空间的信息,我们可以将此信息传递给 GeometryReader 内部的子视图,这有助于我们精确地相对于容器调整其子视图的大小和位置。GeometryReader 视图还确保了所有 iPhone 和 iPad 设备上的视图都能完美对齐,无论其大小如何。

我们将使用 GeometryReader 来调整电梯的所有部分以及内部人的大小和位置。

接下来,让我们在 GeometryReader 的主体内部添加创建电梯内部结构的代码:

//MARK: - INSIDE ELEVATOR SCENE
    Image("inside").resizable()
      .frame(maxWidth: geo.size.width,
      maxHeight: geo.size.height)

Image 初始化器将名为 inside 的图像添加到场景中,然后使用 frame 修改器将此图像的大小设置为屏幕的最大宽度和最大高度。它使用我们用 GeometryReader 视图创建的 geo 代理常量来设置此大小。

预览现在显示电梯的内部:

图 11.1:电梯内部

图 11.1:电梯内部

让我们继续组装电梯,接下来添加门。门将附加动画,以便它们可以滑动打开和关闭,然后稍后我们将为它们添加计时器,以便在按下初始按钮后自动运行。因此,继续在 GeometryReader 视图内部,添加以下代码以设置门:

  //MARK: - ADD THE DOORS
  HStack {
      Image("leftDoor").resizable()
          .frame(maxWidth: geo.size.width)
          .offset(x: doorsOpened ? -geo.size.width / 2 : 4)
      Image("rightDoor").resizable()
          .frame(maxWidth: geo.size.width)
          .offset(x: doorsOpened ? geo.size.width / 2 : -4)
              }

我们将门放入 HStack 中,因为它们需要并排定位。门的大小使用 frame 修改器和 geo 代理常量设置,就像我们对 inside 电梯图像所做的那样。两扇门也附加了 offset 修改器,这将使它们位于屏幕的相对两侧,完全打开。

打开门和关闭的过程由 geo 代理常量处理。当左侧门的 doorsOpened 属性变为 true 时,geo 代理常量将沿着 x- 轴移动门,向左移动并离开屏幕。门移动的量是门宽度的二分之一。左侧门向左移动而不是向右移动的原因是我们给 geo 常量前加了负号。当使用负值并且对象正在沿 x- 轴偏移时,该对象将向左移动,而正值将使对象向右移动。

doorsOpened属性变为false时,offset修改器反转动画,左门关闭。

对于右门,我们做同样的事情,只是使用正值的geo常量,将门沿X轴向右移动以打开它。用于两个门的4-4的值包括在内,以帮助在关闭位置时使两扇门紧密贴合。

到目前为止,这是ElevatorAndPeopleView文件中的场景看起来像什么:

图 11.2:电梯门

图 11.2:电梯门

我们将电梯的内部和门放在一起,并且我们有操作门的动画机制,但直到我们在ContentView中调用此文件,我们才看不到任何动作发生,我们还有一些组装工作要做。

让我们在电梯的外侧添加一个框架,使其看起来像实际的电梯,并为添加电梯按钮提供一个地方。在HStack的闭合括号之后添加以下代码,仍然在GeometryReader视图中:

      Image("doorFrame").resizable()
          .frame(maxWidth: geo.size.width, maxHeight:
            geo.size.height)

在这里,我们正在将doorFrame图像添加到场景中,并且因为我们希望框架围绕整个电梯,所以我们使用了与inside图像相同的代码——我们再次使用了geo代理常量,并通过将图像设置为最大宽度和高度,门框架在电梯门周围创建了一个漂亮的边框。到目前为止的结果如下:

图 11.3:电梯框架

图 11.3:电梯框架

现在,让我们添加打开和关闭门的动画代码,并控制在ContentView中激活后的门的速度和延迟。这只有一行代码,可以放在GeometryReader视图的闭合括号之后:

.animation(Animation.easeInOut.speed(0.09).delay(0.3),
  value: doorsOpened)

在这里,我们使用了animation修改器。然后,我们为speed函数传递了0.09的值,这将控制门开启和关闭的速度。还增加了一个0.3秒的轻微延迟,这将使门开启和关闭的延迟恰到好处,以保持与即将添加的楼层灯的同步。

我们接近完成使用此文件——剩下要添加的部分只有人图像。当然,如果你愿意,可以将人代码放入一个单独的文件中,但由于此文件中的代码不多,在这里继续工作将没问题。

将人放入电梯中

我们将在我们的电梯中添加四个角色,让我们从manOne图像开始——在文件的顶部附近frame修改器之后添加以下代码,在GeometryReader视图中:

  //MARK: - ADD THE PEOPLE
      Image("manOne")
        .resizable().aspectRatio(contentMode: .fit)
        .frame(maxWidth: geo.size.width - 200, maxHeight:
          geo.size.height - 300)
        .shadow(color: .black, radius: 30, x: 5, y: 5)
        .offset(x: 0, y: 250)

我们在这里所做的就是将 manOne 图像引入场景,然后为图像添加正确的宽高比。接下来,我们使用 frame 修改器设置图像的大小,但这次不同的是,我们使用 GeometryReader 的代理常量来动态设置大小;当以这种方式设置图像的宽度和高度时,场景将在所有苹果设备上按比例放大或缩小以适应各种屏幕尺寸。

我正在将宽度和高度值裁剪掉 200 和 300 点,以便图像可以很好地放置在电梯内。一旦我们以我们想要的方式调整了图像的大小和位置,它们将在不同设备上动态缩放,因此我们确实需要最初调整它们的大小和位置。

然后,我们将在图像的 XY 轴周围添加一些 shadow,大小为 30 点,这样当图像放置在闪亮的金属电梯内部时,会给男士一个很好的景深。最后,我们在 Y 轴上偏移图像,使脚位于电梯的地板上。

这就是 manOne 在我们的电梯中的样子:

图 11.4:第一位男士

图 11.4:第一位男士

由于我们还没有连接动画,为了在我们继续工作时打开门并看到电梯内部,你可以简单地注释掉门代码,门图像将消失。

剩下的三个人的代码与我们已经添加的类似,所以我将直接在之前的代码下添加其他三个图像:

      Image("manTwo")
        .resizable().aspectRatio(contentMode: .fit)
        .frame(maxWidth: geo.size.width, maxHeight:
          geo.size.height - 290)
        .shadow(color: .black, radius: 30, x: 5, y: 5)
        .offset(x: 40, y: 230)
        .rotation3DEffect(Angle(degrees: 20), axis: (x: 0,
          y: -1, z: 0))

      Image("manThree")
        .resizable().aspectRatio(contentMode: .fit)
        .frame(maxWidth: geo.size.width - 100, maxHeight:
          geo.size.height - 250)
        .shadow(color: .black, radius: 30, x: 5, y: 5)
        .offset(x: 130, y: 255)

      Image("manFour")
        .resizable().aspectRatio(contentMode: .fit)
        .frame(maxWidth: geo.size.width - 0, maxHeight:
          geo.size.height - 280)
        .shadow(color: .black, radius: 30, x: 5, y: 5)
        .offset(x: -80, y: 265)

manTwomanThreemanFour 图片的代码与 manOne 图片的代码基本相同,只是 frame 修改器、offset 修改器以及它们在电梯中的位置略有不同。

关于 manTwo 代码,我在这个图像上添加了一个旋转效果,因为我想要将这个图像稍微向左转,因为他看起来像是在说话,如果我们将他定位在右边人的方向,看起来会更好。这是通过 rotation3DEffect 修改器实现的;只需将他在 Y 轴上旋转 20 度就足以指向其他图像的正确方向。

这里是我们的电梯,里面装满了人:

图 11.5:其他的人

图 11.5:其他的人

这样,这个文件就完成了。我们现在可以继续创建数据模型文件,并添加定时器和音效所需的功能。

创建数据模型和使用 ObservableObject 协议

让我们创建一个新的文件——尽管这次,选择 DataModel。在这个文件中,我们将放置我们稍后可以通过 ContentView 访问的数据,使用 @ObservableObject 协议。

要使用此协议,我们首先需要导入 SwiftUI 框架:

import SwiftUI

接下来,我们需要一个类来保存所有数据,并且我们还需要使这个类符合 @ObservableObject 协议,所以现在让我们添加它。你可以给这个类起任何名字,但许多开发者喜欢将类的名字与它所在的文件名相同,所以我们将这样做,将类命名为 DataModel

class DataModel: ObservableObject {
                                 }

让我们了解 @ObservableObject 协议的作用。

在以前的项目中,我们使用 @State 属性将数据传递到视图并显示给用户,但这是有限的,因为 @State 属性包装器只能存储控制单个视图状态的值;我们不能创建 @State 属性并将它们传递到其他结构体中,因为它们只在单个结构体中工作。

我们还使用了 @Binding 属性包装器,它有助于通过 @State 属性无法提供的双向链接重新建立与其他视图或结构体的连接。然而,这不会在全局范围内工作,以保留和传递数据到应用程序中的所有文件。

因此,我们现在需要一个可以全局控制应用程序的所有数据和状态的对象,并在属性有任何更改时(无论是通过用户输入还是通过其他方式,如从服务器或网络下载)相应地更新视图。

这就是 @ObservableObject 协议发挥作用的地方。

@ObservableObject 协议定义了三个不同的属性包装器:

  • 一个 @StateObject 属性包装器,它监听 @ObservableObject 的变化并接收新的值

  • 一个 @ObservedObject 属性包装器,它监听 @StateObject 属性的变化并接收那些新值

  • 一个 @Published 属性包装器,它向系统报告所有更改并将这些更改发布到全局视图

使用 @ObservableObject 而不是 @State 属性包装器的优势在于,我们可以更新多个视图以反映任何更改,并且状态可以在应用程序的任何地方进行检查。

现在需要吸收的信息量很大,但随着我们构建数据模型,它将变得更加清晰。我们已经有了一个名为 DataModel 的类,并且它符合 @ObservableObject 协议——这是第一步。接下来,我们想要声明所需的属性,并在它们前面加上 @Published 属性包装器,以便它们可以保存各种数据和状态,并在需要时将它们发布到视图中。现在让我们通过在 DataModel 类内部添加以下属性来实现这一点:

    @Published var doorsOpened = false
    @Published var floor1 = false
    @Published var floor2 = false
    @Published var goingUp = false
    @Published var doorOpenTimer: Timer? = nil
    @Published var chimesTimer: Timer? = nil
    @Published var doorSoundTimer: Timer? = nil

这里有几个属性,希望它们是自解释的。四个布尔值将用于告诉我们电梯的门何时打开,是第 1 层还是第 2 层是活动楼层,以及电梯是上升还是下降。定时器将控制门的开关时间,以及何时播放楼层铃声和门开关声音。

根据列出的属性,我们接下来需要一种方法来从应用的其它部分访问它们。这是通过向上到Root视图并注入数据模型的一个实例来完成的——Root视图是应用的入口点,应用从这里启动。

让我们进入项目导航器中的App.swift文件,并在结构体内部创建DataModel类的实例,发送对ContentView的引用,如下所示:

@main
struct Elevator: App {
    @StateObject private var appData = DataModel()
        var body: some Scene {
        WindowGroup {
            ContentView(appData: appData)
        }
    }
}

正如我提到的,此文件是应用的入口点,如顶部标记的@main包装器所示,它创建Root视图窗口并负责创建应用启动所需的所有对象。由于此文件位于层次结构的顶部,它是创建DataModel实例并将其传递给Root视图、ContentView以使其在应用范围内可用的完美位置。

DataModel实例称为appData,并使用@StateObject属性包装器进行前缀——此包装器将监听@ObservableObject中的更改并接收任何新值。

接下来,我们称为appData@StateObject属性包装器通过将其传递给ContentView初始化器注入到ContentView中,再次使数据模型中的数据在整个应用中可用。

现在,为了完成从我们这里的数据以及项目中的任何视图的数据连接,我们只需在每个我们想要与该模型建立连接的视图中包含一个@ObservedObject属性。@ObservedObject的职责是监听@StateObject属性中的更改并接收这些新值,以便它可以更新视图。

现在让我们进入ContentView并添加一个@ObservedObject属性,以完成AppData类(它包含所有数据)和ContentView(它负责在屏幕上向用户显示所有这些数据)之间的双向绑定。将以下属性添加到ContentView结构体中:

struct ContentView: View {
    @ObservedObject var appData: DataModel
               •••••••
}

现在,我们有一个DataModel的实例,它将为我们监控所有数据;如果数据有任何更改,ObservedObject将知道并相应地更新视图。

此外,如果我们创建了更多文件并需要从这些新文件中访问数据模型,我们只需创建数据模型的一个新实例并在这些文件中使用它,就像我们在这里使用appData实例一样,它们将具有相同的访问权限,并且会随着任何更改而更新。

我们已经准备好了ContentView中使用的DataModel实例,但现在我们需要添加一些函数来设置不同的计时器,这些计时器将触发门的开关,触发楼层灯光,并在适当的时候触发声音效果。

添加数据模型函数

在本节中,我们将创建五个函数,每个函数负责一个特定任务:

  • 一个用于打开和关闭门的函数

  • 表示电梯已到达目的地的铃声声音的播放函数

  • 播放开门和关门声音的函数

  • 点亮楼层指示灯的函数

  • 停止所有计时器的函数

让我们先创建一个打开和关闭门的函数。

添加doorOpenTimer函数

返回到DataModel类,并在最后一个变量直接下方添加以下函数:

func openDoors() {
    doorOpenTimer = Timer.scheduledTimer(withTimeInterval:
      8, repeats: false) { _ in
        self.doorsOpened.toggle()
        }
    }

这个函数被命名为openDoors,它所做的是将doorsOpenTimer对象设置为scheduledTimer方法指定的值。scheduledTimer方法将在一定时间后执行其代码块中的代码——在这种情况下,8 秒。

scheduledTimerWithInterval函数还有一个repeats参数,它允许你重复触发其体内的代码。在代码中,我们将repeats参数设置为false,因为我们只想在电梯按钮被按下时触发其体内的代码。

将触发的代码将涉及切换doorsOpened属性到其相反值。我们将在电梯上添加一个按钮,并在按下该按钮时调用这个函数。当按下时,如果doorsOpened属性是true,它将被切换到false,反之亦然,从而根据需要打开和关闭门。

添加playChimeSound函数

让我们创建另一个函数,该函数将触发铃声声音效果;这个声音表示电梯已到达目的地,将在门打开前播放。在之前的函数下方添加以下函数:

func playChimeSound() {
    chimesTimer = Timer.scheduledTimer(withTimeInterval:
      5.5, repeats: false) { _ in
       playSound(sound: "elevatorChime", type: "m4a")
      }
    }

这个函数被命名为playChimeSound,它和之前我们添加的函数类似。

当函数被调用时,它将chimesTimer属性设置为scheduledTimer方法指定的值,即5.5秒。在 5.5 秒后,scheduledTimer方法将在其体内调用playSound函数。

playSound函数有两个参数——一个用于我们导入到项目中的声音文件名,另一个用于文件的扩展类型。然而,我们还没有创建这个函数,所以我们会得到一个错误!

为了解决这个问题,我们需要创建一个单独的 Swift 文件,我们将称之为PlaySound。然后,将以下代码添加到这个文件中:

import Foundation
import AVFoundation
var audioPlayer: AVAudioPlayer?
func playSound(sound: String, type: String) {
    if let path = Bundle.main.path(forResource: sound,
      ofType: type) {
        do {
            audioPlayer = try AVAudioPlayer(contentsOf:
            URL(fileURLWithPath: path))
            audioPlayer?.play()
        } catch {
            print("ERROR: Could not find and play the sound
              file!")
        }
    }
}

如我们在之前的项目中做的那样,我们导入了AVFoundation框架以访问我们需要的音频类和方法。接下来,我们创建了一个audio player实例,然后添加了playSound函数。

你应该熟悉这个函数的工作方式;我们只需要在任何需要播放声音的文件中调用这个函数,将声音文件名传递给其sound参数,将扩展类型传递给其type参数。现在,playChimeSound函数应该能够无错误地播放。

现在,让我们回到DataModel类,并继续添加我们需要的其余函数。

添加 playDoorOpenCloseSound 函数

当电梯门开启和关闭时,会发出特有的声音,类似于机械的呼啸声。我们想在项目中添加这个声音,并且我们需要一个函数能够在正确的时间触发它。让我们在之前的函数之后立即做这件事:

func playDoorOpenCloseSound(interval: TimeInterval) {
        doorSoundTimer = Timer.scheduledTimer(withTimeInterval:           interval, repeats: false) { _ in
            playSound(sound: "doorsOpenClose", type: "m4a")
        }
    }

playDoorOpenCloseSound 是一个函数,我们在这个函数中将 doorSoundTimer 属性设置为 scheduledTimer 方法指定的值,并且当它在 ContentView 中被调用时,它将使用作为间隔参数的值传递到这个函数中。当这个函数被调用时,在经过间隔时间后,它会触发其体内的代码,其体内是之前见过的 playSound 函数,这个函数将播放门的开启和关闭声音。

添加 floorNumbers 函数

到目前为止,我们创建的函数都相对简单直接,每个函数只有一到两行代码。但是为了让楼层指示灯正常工作,我们需要增加一些复杂性和一点逻辑。

这个函数的目标是在电梯到达目标楼层时激活适当的楼层灯光,并在电梯离开该楼层时关闭灯光,就像一个真正工作的电梯那样。所以,让我们在之前的函数下面添加以下函数:

func floorNumbers() {
        ///light up floor 1 as soon as the button is
          pressed, making sure floor 2 is not lit first
        if !floor2 {
            floor1.toggle()
        }
        ///check if the doors are opened, if not, open the
          doors and play the chime sound
        if !doorsOpened {
            openDoors()
            playChimeSound()

        ///going up - wait 4 seconds and turn on the floor
          2 light, and turn off the floor 1 light
            if goingUp {
              withAnimation(Animation.default.delay(4.0)) {
                    floor2 = true
                    floor1 = false
                }
    ///once at the top, and the button is pressed again to 
      go down, wait five seconds then turn off the floor 2 
      light and turn on the floor 1 light
              withAnimation(Animation.default.delay(5.0)) {
                    floor1 = true
                    floor2 = false
                    playDoorOpenCloseSound(interval: 8.5)
                }
            } else if !goingUp {
              withAnimation(Animation.default.delay(5.0)) {
                    floor1 = true
                    floor2 = false
                    playDoorOpenCloseSound(interval: 8.5)
                }
              withAnimation(Animation.default.delay(5.0)) {
                    floor2 = true
                    floor1 = false
                }
            }
        }
    }

这个函数被命名为 floorNumbers,它一开始会检查各种属性以确定它们是 true 还是 false

函数中的第一个 if 语句检查 floor2 属性是否为 false —— 如果是 false,那么我们希望将 floor1 属性切换到其相反值。我们总是想确保 floor1floor2 变量的值是相反的,因为它们代表电梯到达的不同楼层。

在第一个 if 语句之后,还有一个 if 语句。这个语句检查门是否开启;如果门没有开启,那么代码将打开门并播放铃声。

然后,在 if 语句内部还有一个 if 语句,这使得它成为一个嵌套的 if 语句。如果一个 if 语句检查的变量是 false,它将不会运行其体内的代码;它只会移动到文件中的下一行代码。这个 if 语句检查 goingUp 属性是否为 true;如果是 true,那么我们将 floor2 设置为 truefloor1 设置为 false,因为我们想要点亮 floor2

这段代码都是在 withAnimation 函数中触发的,延迟 4 秒。这是在打开 floor2 灯光并关闭 floor1 灯光之前需要等待的时间。withAnimation 函数将添加一个默认动画,这是一个淡入淡出动画,当打开和关闭灯光时看起来很好——在我们的案例中,是电梯楼层灯光。

当电梯到达顶层并且再次按下按钮以向下行驶时,电梯会等待5秒,然后floor1灯亮起,floor2灯熄灭,因为电梯正在向下行驶。此外,我们将在8.5秒后触发开门和关门的声音,这足以让电梯到达底层,此时声音需要播放。同样,我们是在withAnimation函数的主体中这样做,因此它为开关灯添加了默认的淡入淡出动画。

接下来,我们需要为goingUp变量不是true的情况使用类似的代码,这样两种状态,truefalse,都能正确地操作灯光和声音。我们在else if语句中这样做。else if为它前面的if语句提供了一个备选方案,如果那个if语句是false的话。所以在这里,else if检查goingUp是否为false;如果是,代码将在 5 秒后打开floor1灯,因为电梯正在向下行驶,关闭floor2灯,然后播放开门和关门的声音。否则,在 5 秒后,它将执行相反的操作,重新打开floor2并关闭floor1

现在正在使用的逻辑是用于操作计时器并在正确时刻触发适当的音效,这是通过使用ifelse if语句实现的。现在,关于计时器的问题在于,当创建几个计时器时,它们可能会相互重叠,并在应用中引起意外的后果,因此我们需要在创建新的计时器之前停止它们。

添加停止计时器函数

我们确实在应用中有很多计时器在不同的时间触发,其中一些计时器在后台可能会相互重叠,并在应用中引起意外的副作用。我们需要停止任何已经完成其任务的计时器,以确保没有问题。在需要时将创建新的计时器,但它们在完成任务后都应该被停止。

因此,让我们在单独的函数中处理所有计时器的停用。将以下最终函数添加到文件中:

 func stopTimer() {
        doorOpenTimer?.invalidate()
        doorOpenTimer = nil
        chimesTimer?.invalidate()
        chimesTimer = nil
        doorSoundTimer?.invalidate()
        doorSoundTimer = nil
    }

此函数名为stopTimer,它使所有创建的计时器无效,并将它们设置为nil

无效化一个计时器实际上会阻止计时器再次触发,并请求将其从其运行循环中移除。将对象设置为nil相当于将变量设置为零;它确保它被完全停止。我们将在电梯按钮按下时调用此方法,并使其成为第一个调用的方法,从而移除可能仍在运行的任何计时器。

现在,DataModel已经完成,所有函数和属性都已设置好,可以在应用的任何地方使用;我们将在ContentView中使用它们。让我们转到那里,开始将这些事情组合起来,以便我们可以开始看到一些结果。

添加背景、按钮和动画门

让我们继续并开始填充 ContentView,以便我们可以看到电梯,然后我们可以进行动画处理。

首先,我们将为整个场景添加一个黑色背景。为此,在 appData 变量之后添加一个常量来保存一些颜色:

let backgroundColor = Color(UIColor.black)

接下来,在 body 属性内部,让我们添加 ZStack 并调用我们的 backgroundColor 常量,为屏幕设置颜色:

var body: some View {
        ZStack {
            backgroundColor.edgesIgnoringSafeArea(.all)
               }

现在,我们需要调用 ElevatorAndPeople 视图,以便我们可以在文件中使其可见。在 ZStack 内添加以下代码,实际上,我们将在此文件中添加的所有后续代码都将位于此 ZStack 内:

          //MARK: - ADD THE PEOPLEANDELEVATOR VIEW
            ElevatorAndPeopleView(doorsOpened: 
              $appData.doorsOpened)

如我们之前所见,要显示 ContentView 中的另一个视图,我们只需在这里的 body 属性中调用它,并传入我们创建的用于访问模型类的绑定变量。记住,我们使用美元符号来访问绑定变量,这告诉系统我们正在双向连接到另一个视图。

注意,我们可以通过使用 appData 观察对象,并通过输入一个点来访问模型文件中的任何属性或函数。在这里,我选择了 doorsOpened 属性,我们将在按钮中切换该属性。

关于按钮,我们现在就添加电梯按钮;我们将它放在电梯框架的左侧,当按下时,门将打开和关闭。在上一行下添加以下代码:

//MARK: - ELEVATOR BUTTON
    GeometryReader { geo in
      Button(action: {
           appData.doorsOpened.toggle()
        }) {
           ///if the doors are opened, make the button 
             white, otherwise make it black
            Circle().frame(width: 10, height: 10)
              .foregroundColor(appData.doorsOpened ? .white 
              : .black)
                .overlay(Circle().stroke(Color.red,
                  lineWidth: 1))
                .padding(5)
                .background(Color.black)
                .cornerRadius(30)
        }.position(x: (geo.size.width / 33), y:
          (geo.size.height / 2))

此代码首先使用 GeometryReader。正如我们所见,使用 GeometryReader 将使视图对齐,以便它们完美地适应场景,并且它们将通过访问闭包内的 geo 常量在具有其他尺寸的设备上正确调整大小。

我们然后在 GeometryReader 内创建一个按钮——当按钮被按下时,它将运行其体内的所有代码。我们首先想查看门是如何工作的,所以我添加了当按钮被按下时切换 doorsOpened 属性的代码。

让我们看看按钮代码中我们还做了什么,通过查看进行样式设置的按钮闭包。代码使用 Circle 视图创建按钮的圆形形状。我给它设置了 10 x 10 的尺寸,然后添加了几个修饰符来帮助进行样式设置:

  • 首先,我们使用 foregroundColor 修饰符为圆形设置颜色,当门打开时将是白色,当门关闭时将是黑色。这是通过访问我们的模型实例 appDataObservedObject 并从模型中调用 doorsOpened 属性来实现的。

  • 下一个修饰符是 overlay,我们传递另一个 Circle 视图。然后,通过添加 stroke 修饰符,它变成了一个描边圆形(而不是填充圆形)。描边的颜色设置为红色,线宽为 1 点。这个描边将看起来像按钮中的一个红色小环,就像你在实际的电梯按钮中有时看到的那样。

  • 然后,我们使用 padding 修饰符,值为 5 点。

  • 然后,我们使用一个background颜色修饰符,位于红色环的下方。

  • 最后,在黑色背景颜色视图中添加一个cornerRadius值为30,因为默认情况下它是一个矩形,我们需要一个圆角形状。

  • 现在,我们只需要将按钮精确地放置在电梯框架所需的位置,我们可以使用position修饰符来实现,传递geo代理常量,它具有 iPhone 屏幕的精确尺寸。在这里,代码通过使用按钮的宽度在 X 轴上定位按钮,并将GeometryReader(iPhone 屏幕的宽度)除以 33;这个值将按钮精确地移动到电梯框架的左侧。现在我们已经有了按钮的 X 位置,我们需要 Y 位置。再次使用geo常量,并将height除以 2,我们可以在 Y 轴的框架中间放置按钮。

有了这些,按钮就完成了。让我们尝试到目前为止的动画:

图 11.6:开门和按钮

图 11.6:开门和按钮

按下按钮,你应该看到门打开;如果你再次按下按钮,门将关闭。而且门工作得非常完美,以合适的速度打开和关闭,当门打开时按钮会改变颜色。

让我们继续,并在电梯中添加楼层指示灯。

添加楼层指示灯

如我们所知,电梯通常在框架顶部有灯光,以让人们在任何时候都知道电梯在哪个楼层。让我们通过添加代表电梯一、二楼的灯光来模拟这一点。将以下代码直接添加到我们刚刚写的上一行代码之后:

//MARK: - ADD THE FLOOR LIGHTS
  HStack {
      Image(systemName: "1.circle")
          .foregroundColor(appData.floor1 ? .red : .black)
          .opacity(appData.floor1 ? 1 : 0.3)
      Image(systemName: "2.circle")
          .foregroundColor(appData.floor2 ? .red : .black)
          .opacity(appData.floor2 ? 1 : 0.3)
  }.position(x: (geo.size.width / 2), y: (geo.size.height *
    0.02) + 2)
      .font(.system(size: 25))

我们从HStack开始,这样我们就可以将两个圆形图像并排放置。这些是系统图像,它们有特定的名称1.circle2.circle。第一个圆代表第一层,第二个圆代表第二层。

第一个圆灯的颜色将取决于floor1变量——如果它是true,它将使颜色变红;否则,如果它是false,它将变成黑色。这个圆的透明度也将取决于floor1变量——再次,如果true,圆将具有完全不透明的外观;否则,我们将透明度设置为.3。我们对两个圆使用相同的代码。

然后,通过在HStack的末尾放置position修饰符,我们可以将两个圆直接放置在框架顶部,并通过再次使用geo代理保持位置不变。最后,我们将楼层数字的字体大小设置为25

这样就完成了楼层指示灯。

最后,我们只需要在按钮体内调用剩余的函数,这样当按钮被按下时,所有的灯光和声音都会在定时器上工作。因此,添加以下代码以完成项目:

 //MARK: - ELEVATOR BUTTON
    GeometryReader { geo in
        Button(action: {
             appData.stopTimer()
            appData.playDoorOpenCloseSound(interval: 0.5)
              appData.doorsOpened.toggle()
            appData.goingUp.toggle()
              appData.floorNumbers()
        }) {

代码使用appData实例访问模型功能,并在按钮按下时运行每个功能。

现在,运行项目,你会看到当按下按钮时,门会打开,你会看到里面的人。注意,按钮会改变颜色,楼层指示灯亮起,同时伴有铃声和门开启的声音。当电梯在楼层之间移动时,门、灯光和声音将自行在正确的时间工作。

图 11.7:完成的项目

图 11.7:完成的项目

这是一个很好的动画,它非常真实地模拟了实际电梯的时间。

摘要

我们现在已经完成了电梯项目。让我们看看我们完成了什么。

我们使用了GeometryReader和代理常量来向项目中添加图片并将它们放置在所需的位置,这将根据显示它们的设备(无论是 iPhone 还是 iPad)动态调整项目中所有图片的大小。

我们创建了DataModel来存储所有应用程序的数据和功能,并使用@ObservableObject协议访问所有这些数据。

我们使用计时器在场景的不同时刻触发门和灯光动画,使动画自行发生。我们还添加并设计了用于改变颜色的按钮,当按下时,我们使用计时器使地板灯光开启和关闭。

在这里学到的技能可以很有用,并应用于其他项目。例如,如果你正在制作一个有多个级别的游戏应用程序,也许你可以将电梯场景融入游戏中,在用户完成某个技能后将其带到另一个级别,或者寻找升级物品。

怎么样,来个挑战?为了进一步推进项目,看看你是否能向场景中添加更多楼层。或者,使用我们在秋千项目中使用的相同技术来动画化电梯内的人?切割图片,使不同的部分以不同的方式移动——例如,你可以切割第二个人的嘴巴,使其在门打开时看起来像在说话。你也可以动画化腿,使其在电梯内轻微摇晃,或者动画化身体,使其轻微摇摆,基本上模拟人类动作。

在下一章中,我们将开始组装一个语言学习游戏,并动画化 UI 的各个方面,使游戏流畅且有趣。我们还将使游戏支持三种不同的语言——英语、西班牙语和意大利语——以便它能吸引广泛的语言学习者。

第十二章:创建一个文字游戏(第一部分)

在这个项目中,我们将开始创建一个“完成单词”游戏,我们将在下一章中完成它。这个单词游戏将要求用户使用给定单词中的字母来查找单词。为了给游戏增加一点变化,我们将提供三种不同的语言 – 英语、西班牙语和意大利语。

您将学习到的一些新内容包括如何添加和自定义PickerView,以几种不同的方式实现用户反馈(包括弹出警报、触觉和声音),以及向用户界面添加各种动画,包括弹簧动画。

因此,在本章中,我们将涵盖以下主题:

  • 设置项目和创建数据模型

  • 通过添加文本字段和列表来构建 UI

  • 在列表中的每个单词旁边显示字符计数

  • 检查输入的单词是否有重复

  • 通过按钮点击添加随机单词

  • 检查用户输入的单词是否可行

  • 检查用户输入的单词是否为真实单词

  • 检查用户输入的单词是否有效

  • 创建带有info按钮的HeaderView

  • 创建PickerView

技术要求

您可以从 GitHub 上的Chapter 12Chapter 13文件夹下载资源和完善的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

设置项目和创建数据模型

首先,创建一个新的 Xcode 项目,并将其命名为Find Words。然后,在 GitHub 仓库中,转到Chapter 12Chapter 13文件夹;这个文件夹将包括三个子文件夹,分别称为Language DataImagesSound。将Images文件夹添加到资产库中,将其他文件夹添加到项目导航器中。

接下来,我们将创建一个数据模型文件来保存github.com/PacktPublishing/Animating-SwiftUI-Applications应用程序的数据 – 要这样做,请按Command + N,选择DataModel。在这个文件中,通过在文件顶部添加以下代码来导入 SwiftUI:

import SwiftUI

如我们之前所做的那样,我们添加一个类来保存所有属性和方法。我们将把这个类命名为DataModel,并且它需要遵守ObservableObject协议,以便我们稍后可以访问这些数据。然后,我们将添加所需的属性:

class DataModel: ObservableObject {
    @Published var allWordsInFile = [String]()
    @Published var baseWord = ""
    @Published var userEnteredWord = ""
    @Published var userEnteredWordsArray = [String]()
    @Published var letterCount = ""
    @Published var showSettings: Bool = false

    //error properties
    @Published var errorMessageIsShown = false
    @Published var errorTitle = ""
    @Published var errorDescription = ""

    //properties to stpre in user defaults
    @AppStorage ("selectedSegment") var selectedSegment:
      Int = 0
    @AppStorage ("englishIsOn") var englishIsOn: Bool = 
      false
    @AppStorage ("spanishIsOn") var spanishIsOn: Bool = 
      false
    @AppStorage ("italianIsOn") var italianIsOn: Bool = 
      false

    //splash view property
    @Published var change = false  
}

让我们分解代码:

  • 首先,我们有一个名为allWordsInFile的数组,它将保存游戏所需的所有单词。我们将使用三个单独的文件,一个用于英语,一个用于西班牙语,一个用于意大利语。这个数组将根据所选的语言加载以进行游戏。

  • 接下来是一个baseWord属性,它将包含用户正在处理并尝试从中找到新单词的单词。

  • 然后,我们还有一个名为userEnteredWord的属性,它将在用户将单词输入到文本字段时保存用户的单词。

  • 接下来是另一个名为userEnteredWordsArray的数组,它将保存所有用户的猜测单词,这样我们就可以跟踪他们已经完成了哪些内容。

  • 接下来是一个名为letterCount的属性,它将用于跟踪每个单词使用的字母数量,这样我们就可以计算每个单词的平均字母数。例如,如果用户只选择三个字母的单词,我们将在屏幕上显示该单词的平均字母数。

  • 接下来是一个布尔值,它将打开SettingsView,允许用户更改游戏的语言。

  • 接下来是三个错误属性,我们将使用它们在用户拼写单词错误或输入重复单词时显示警告,这些单词已经在列表中。

  • 接下来是所谓的AppStorage属性。这些用于在内存中保存用户的语言设置,这样当他们在稍后关闭应用并返回时,他们的设置会被保留。当我们给属性加上AppStorage包装器时,手机上的一个内存位置会保留用户设置。

  • 接下来是一个用于显示启动屏幕的属性。启动屏幕是用户在应用在后台加载时将短暂看到的开场场景。

让我们继续,现在将我们需要的所有方法添加到DataModel中,就在我们刚刚添加的属性下面:

 //FUNCTIONS
     //starts the game off with a random word by looking in 
       the app's bundle for the language file
    func getRandomWord() {
        guard let wordsURL = Bundle.main.url(forResource: 
        setWordLengthAndLanguage(), withExtension: "txt"),
        let wordsConverted = try? String(contentsOf:
          wordsURL) else {
        assert(false, "There was a problem loading the data
          file from the bundle.")
            return
        }
        let allWordsInFile = 
          wordsConverted.components(separatedBy: "\n")
        baseWord = allWordsInFile.randomElement() ?? 
          "SwiftUI"
    }
    //sets the language for the game
    func setWordLengthAndLanguage() -> String {
      return ""
    }

    //adds a new word to the game
    func addWordToList() {

    }

    //check to see if the word is a duplicate
    func isWordDuplicate(word: String) -> Bool {
        return false
    }

    //check to see if the word is possible given the base
      word's letters
    func isWordFoundInBaseWord(userGuessWord: String) ->
      Bool {
        return false
    }

    //check to see if the word is a real word in dictionary
    func isWordReal(word: String) -> Bool {
        return false
    }
    //error message
    func displayErrorMessage(messageTitle: String,
      description: String) {

    }

让我们也将这段代码分解一下:

  • 我们有一个方法可以从语言文件中获取一个随机单词

  • 我们有一个设置单词长度的函数——可以是 7、8 或 9 个字母——以及语言选择——可以是英语、西班牙语或意大利语

  • 有一个函数用于将用户的单词添加到列表中,这样他们就可以看到他们迄今为止找到的所有单词

  • 然后是一个检查他们的单词是否重复的函数;我们不希望在列表中有重复项

  • 然后,一个函数用于检查根据他们正在处理的单词,这个单词是否可能

  • 我们还有一个函数用于检查单词是否是所选语言中的真实单词(我们不希望有虚构的单词!)

  • 然后最后,一个函数用于向用户显示错误信息,如果他们输入了一个不存在的单词或无法构成的单词

如您从代码中看到的,我现在只填写了一个函数,其余的函数只是作为return语句而存在。这很有帮助,因为我们仍然可以在界面中继续编写代码,将这些函数称为占位符,代码仍然可以干净地构建,即使它目前还没有任何功能。

我们很快就会填写其他函数的主体,但现在让我们看看我添加代码的那个函数——getRandomWord()。这个函数首先尝试访问应用包,这是我们放置语言文件的地方。当我们向项目中添加文件时,它们会被放入应用包中。应用包是一个文件夹,其中存储并使用所有应用文件,用于使应用工作。我们需要访问包并找到那些语言文件的路径,这样我们就可以加载它们包含的单词并在游戏中使用它们。

要访问它们,Swift 为我们提供了一个名为url(forResource:)的方法来定位文件的路径——我们只需将文件名输入第一个参数,扩展名输入第二个参数。目前,我们只是将要加载的七个字母的英文单词文件(但稍后我们将访问其他大小和不同语言的文件)。

对于扩展名,我将其设置为txt以表示文本文件。如果我们找到该文件,那么我们将进入下一步,尝试将该文件转换为字符串,以便我们可以对其进行操作。如果代码能够完成这个任务,那么它将使用components(separatedBy:)方法将文件中的单词分开,并传入换行符,这样每个单词都会单独在一行上。这有助于我们稍后逐个访问这些单词。

接下来,我们需要从文件中获取一个随机单词并将其存储在baseWord属性中。注意,我们需要在这里使用三元运算符。这是因为randomElement函数是一个可选函数。因此,它可能没有单词可以获取,所以,我们提供了一个备选单词,以防文件中没有单词(这种情况只会发生在文件损坏的情况下,这种情况很少发生)。

最后,如果我们在这个过程中遇到问题,我们将使用assert函数。assert函数接受两个参数:一个要检查的条件,以及如果条件为false时显示的消息。如果条件为false,则将显示消息,并且应用程序将在调试环境中停止。在生产环境中,assert语句将被忽略。

我们稍后将会使用getRandomWord()函数来获取一个用户可以玩耍并尝试从中找到新单词的随机单词。

好的,所有属性和函数都已就位,其中一个函数已完成。

现在,让我们进入App.Swift文件,创建DataModel类的实例,然后将该实例注入到ContentView中,以便数据模型可以在应用程序的任何地方使用:

@main
struct FindWordsApp: App {
    @StateObject private var appData = DataModel()

    var body: some Scene {
        WindowGroup {
            ContentView(appData: DataModel())
        }
    }
}

这应该对你来说很熟悉——我们已经创建了DataModel实例并将其传递到根视图ContentView中。我们还将得到一个错误,因为我们现在需要更新ContentViewPreviews。让我们在ContentView中添加以下修改,并通过添加DataModel实例来完成实现Observable对象协议,这样我们就可以将数据传递到视图中:

@ObservedObject var appData: DataModel
Now let's update the Previews at the bottom of the file 
  too:
ContentView(appData: DataModel())

然后,让我们添加一个属性来在ContentView顶部显示一个随机单词:

@State var wordToPlayFrom = "Click for Word"

wordToPlayFrom属性将被设置为随机单词,这将作为用户开始玩耍并寻找新单词的单词。它被设置为会提示用户开始游戏的字符串。

好的,我们的数据模型现在已经在ContentView中设置好了;让我们接下来专注于构建用户界面。

通过添加文本字段和列表构建 UI

要构建我们的用户界面,首先,我们将添加一个文本字段视图,以便用户可以输入他们的答案,然后我们将添加一个列表视图,以便将所有用户的单词列在表格中。

要做到这一点,进入ContentViewbody属性,移除现有的TextImage视图的样板代码,并添加以下代码:

//MARK: - TEXTFIELD - LIST TABLE
    VStack(alignment: .center, spacing: 15) {
       //MARK: - TEXT FIELD AND LIST
            TextField("Enter your word", text: 
              $appData.userEnteredWord)
                .textInputAutocapitalization(.never)
             .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal, 55)
                .onSubmit(appData.addWordToList)
         ///List view to display the user input
         List(appData.userEnteredWordsArray, id: \.self) {
           word in
                Text(word)
            .foregroundColor(.black)
            .font(.system(size: 18))
        }
        .frame(width: 285, height: 190, alignment: .center)
        .cornerRadius(10)
        .foregroundColor(Color.blue)
        .font(.system(size: 50))
    }

在这里,我们添加了一个文本字段,以便我们可以获取用户的输入,同时将.textInputAutocapitalization设置为never,这样自动大写就不会将用户输入到文本字段中的内容自动大写。我们导入到项目中的单词文件都是小写的,因此关闭自动大写有助于访问这些单词并检查字母。

接下来,文本字段获得一点样式和一点填充,然后我们调用onSubmit修饰符并将addWordToList属性传递给它。这个修饰符的作用是在用户输入一个单词并按下Enter键时执行一个动作。执行的动作是调用我们数据模型中的addWordToList方法。该方法将处理用户的输入并查看他们的单词是否允许使用,基于某些标准,例如单词是否在所选语言中是可能的,以及他们是否由基本单词中的字母组成。

注意,为了访问我们的数据模型,我们必须使用我们在文件顶部创建的DataModel实例的appData;这使我们能够访问我们在DataModel中创建的所有方法和属性。

继续编写代码,我们接下来转向List视图,它创建一个列表来显示用户的单词。这使用id参数来唯一标识guessedWords数组中的每个单词。当我们遍历(循环)userEnteredWordsArray时,我们需要一种方法来识别其内容——该数组中的每个元素都必须是唯一的,才能使用它,幸运的是,每个单词确实是唯一的(没有两个单词是相同的)。所以,这里看到的id参数所说的是,它将使用单词的自身名称作为其标识符(self部分),并且我们可以访问数组中的元素。

之后,我们做了一些更多的样式调整。我们将单词的文本颜色设置为black,大小设置为18点。然后,我们设置了List视图的框架大小,以及将其颜色更改为blue并提供cornerRadius属性。

现在,如果我们运行应用程序,我们可以在文本字段中输入单词,但当我们按下Enter键时,单词并没有被放置到List视图中:

图 12.1:输入到文本字段中的单词

图 12.1:输入到文本字段中的单词

单词没有进入列表的原因是我们还没有在DataModel类中填写空的addWordToList函数。现在让我们用一些代码来填写这个方法,这样用户的输入单词就可以在列表中显示:

func addWordToList() {
    let usersWord = userEnteredWord.trimmingCharacters(in:
      .whitespacesAndNewlines).lowercased()
    //guard against one or two letter words - they are too 
      easy
    guard usersWord.count > 1 else {
        return
    }
    userEnteredWordsArray.insert(usersWord, at: 0)
    userEnteredWord = ""
  }

在这里,我们创建了一个userWord常量,它将存储用户添加到列表中的新单词。我们首先检查用户的单词中是否有空格。这样,如果用户在文本字段中不小心输入了一些额外的空格,trimmingCharacters函数将消除它们。接下来,使用lowercased函数将单词转换为小写。

然后,我们使用guard语句,它与if语句非常相似,用于检查用户输入的单词是否有超过一个字母。如果有,它将进入下一行代码。否则,方法将立即返回,不再运行任何更多代码。当代码“从”一个方法“返回”时,这意味着该方法的代码已经运行完毕,即使在该方法内部还有更多代码,也不会运行。我们之所以检查用户的单词是否至少有两个字母,是因为这些单词太简单了。尽管在任何给定语言中,单字母单词并不多,但为什么不把它们全部删除呢?

在用户的单词被修剪并转换为小写后,它就被添加到userEnteredWordsArray中,并使用insert(at:)方法插入到索引零的位置。这个索引位于数组的开始处,我们之所以在数组开头插入单词而不是将其添加到数组的末尾,是因为我们希望每个新单词都出现在列表的顶部,以便它能够立即被看到。

最后,我们将newWord属性重置为空字符串,这样当用户按下Enter键时,实际上就清除了Text视图,因此他们可以在准备好时输入另一个单词。

现在,如果我们运行这个项目,我们可以看到当我们将单词输入到文本字段并按下Enter键时,它们现在会出现在列表中:

图 12.2:单词现在被输入到列表中

图 12.2:单词现在被输入到列表中

新单词将出现在列表的顶部。默认情况下,List视图也是可滚动的,因此用户可以轻松地上下滚动以查看之前输入的单词。

让我们继续进行 UI 设计,并显示每个单词包含的字符数。

在列表中的每个单词旁边显示字符计数

我还想要在每个单词旁边添加一个数字,表示用户输入的单词中有多少个字母。稍后,我们将从每个单词中取出这个数字并将它们相加,以得到用户在输入单词长度方面的平均表现。

我们可以将这个数字放在一个圆圈里,幸运的是,这非常简单,因为 Swift 为我们提供了带有数字的系统圆形图像,这些图像已经创建好了;我们只需要调用它们。所以,回到ContentView,然后在List视图中,让我们将Text视图放在一个HStack(这样它们就是并排的)中,并添加一个用于数字圆圈的Image视图:

List(guessedWordsArray, id: \.self) { word in
        HStack {
            Image(systemName: 
              "\(word.count).circle")
            Text(word)
        } .foregroundColor(.black)
          .font(.system(size: 18))
    }

Image视图获取一个圆形的系统图像。word.count代码语句获取用户单词中的字母数,并在圆圈中显示这个数字。现在如果我们运行代码,所有输入的单词都将显示它们的字母计数,位于给定单词的左侧圆圈中:

图 12.3:圆圈中显示的字母计数

图 12.3:圆圈中显示的字母计数

让我们继续检查用户的单词是否有重复。

检查输入的单词是否有重复

现在我们正在显示用户输入的单词,那么我们检查它们以确保它们是字典中的真实单词,并且列表中没有重复的单词怎么样?我们已经为这些检查设置了所有函数的框架,所以让我们开始填充它们。

我们将首先填充的函数是isWordDuplicate,我们将对其进行修改,使其看起来像这样:

 func isWordDuplicate(word: String) -> Bool {
    return userEnteredWordsArray.filter { $0 == word 
      }.isEmpty
  }

这所做的就是检查userEnteredWordsArray是否包含用户在Text字段中输入的单词。这是它的工作原理。这个函数中的return语句使用filter方法对userEnteredWordsArray数组进行操作。filter方法接受一个闭包作为其参数,该闭包对数组的每个元素进行评估。在这种情况下,闭包检查数组的当前元素($0)是否等于传递给函数的单词。

这个filter操作的结果是一个新数组,它包含所有与给定条件匹配的userEnteredWordsArray中的元素。如果这个新数组不为空,这意味着单词已经存在于userEnteredWordsArray中,所以函数返回false。如果新数组为空,这意味着单词没有重复,所以函数返回true。使用数组的isEmpty属性来检查数组是否为空。

现在,仅仅填充函数还不够,我们需要在addWordToList函数中调用这个函数,这样我们就可以在单词被添加到列表之前使用它来检查新单词。让我们现在就做这件事——在addWordToList函数中,在创建userWord实例之后,添加以下内容:

func addWordToList() {
    let usersWord = 
      userEnteredWord.lowercased().trimmingCharacters(in: 
      .whitespacesAndNewlines)
    //is the word a duplicate
   if !isWordDuplicate(word: usersWord) {
    displayErrorMessage(messageTitle: "You already used 
      this word", description: "Keep looking!")
    return
        }
            •••••••••
}

这段代码通过在isWordDuplicate函数前使用!运算符来检查它是否返回true。记住,isWordDuplicate函数返回一个布尔值,要么是true要么是false,所以如果这个函数返回false,那么if语句就会运行其体内的代码,这是一个名为displayErrorMessage的另一个函数。这将向用户显示一个错误消息,告诉他们他们的单词是重复的(我们还没有设置我们的错误消息系统,但我们很快就会这么做!)。

现在,我想让你回到ContentView中运行应用,输入一个单词,并看到它在列表中显示。然后,再次输入相同的单词,你会注意到你无法这样做。guard语句阻止了这种情况的发生,因为它检查并看到该单词已经被输入到列表中。

接下来,我们想要添加一个按钮,该按钮可以从我们导入到项目中的单词文件中随机获取一个单词,这给用户提供了一个单词来玩耍并尝试找到单词。

通过按钮点击添加随机单词

在我们继续填充方法和内容之前,我们需要一个按钮来提供一个用户可以用来玩耍并尝试找到单词的随机单词。毕竟,只是输入随机单词并在列表中显示它们目前并不是一个挑战。

我们将首先提供一个包含数千个七个字母的单词文件,当按下按钮时,其中一个单词会随机显示在屏幕上。我们还将给用户选择七个、八个或九个字母的单词,以及稍后选择其他语言的选择,但现在,让我们只坚持使用七个字母的英语单词。

如果你还没有这样做,请转到 GitHub 文件夹,选择Language文件文件夹,并将该文件夹的内容拖放到项目导航器中。完成后,选择所有文件,右键单击并选择Languages——这有助于在项目导航器中保持一切井井有条。

现在这些文件已经添加到项目中,并且我们已经构建了getRandomWord函数,该函数可以从指定的文件中获取随机单词,所以现在我们需要调用这个函数,以便向用户显示单词——我们可以通过按钮来实现这一点。直接在VStackText字段代码上方添加以下代码:

 //MARK: - BUTTON
        Button(action: {
            appData.getRandomWord()
            wordToPlayFrom = appData.baseWord
        }){
            ZStack {
                Image("background").resizable()
                    .renderingMode(.original)
                    .frame(width: 125, height: 50)
                    .cornerRadius(15)
                Text("New Word")
                    .foregroundColor(.white)
             }
        }.padding(7)
            .shadow(color: .black, radius: 2, x: 1, y: 1)
            .shadow(color: .black, radius: 2, x: -1, y: -1)

        Text(wordToPlayFrom)

这段代码创建了一个按钮。在按钮的action参数中,我们使用appData实例调用getRandomWord函数。我们还把wordToPlayFrom属性设置为baseWord的值,因为baseWord包含将在屏幕上显示的随机单词。

现在每次按下按钮时,都会从我们的文本文件中随机选择一个单词放入baseWord,然后该单词被放入wordToPlayFrom属性以供显示。

在按钮代码的末尾,我们创建了一个Text视图,在屏幕上显示wordToPlayFrom。按钮随后被赋予木纹背景图片、轻微的圆角、一点阴影和新单词字样。

在添加了这段代码后,按钮应该看起来像这样:

图 12.4:创建随机单词的按钮

图 12.4:创建随机单词的按钮

现在,按下按钮,你将得到一个用户可以处理的随机单词。同时,请注意,当你按下按钮时,它实际上看起来像是被按下了;我们默认获得这种行为,这实际上增加了用户体验。

游戏正在进行中,但我们想要继续检查用户输入的单词,看看它是否可以由随机单词组成。接下来我们就来做这件事。

检查用户输入的单词是否可行

仅查找重复的单词是不够的。现在我们已经生成了一个随机的七个字母的单词,我们想要确保用户输入的单词可以由随机单词中的字母组成。例如,如果随机单词是books,那么这是无效的,因为s不在随机单词中。

让我们回到DataModel类,并在isWordFoundInBaseWord函数中添加代码,以便我们可以检查单词是否可以由baseWord组成:

  //check to see if the word is possible given the baseWord 
    letters
   func isWordFoundInBaseWord(userGuessWord: String) -> 
     Bool {
        var comparisonWord = baseWord
        return userGuessWord.allSatisfy { letter in
            guard let position = 
              comparisonWord.firstIndex(of: letter) else {
                return false
            }
            comparisonWord.remove(at: position)
            return true
        }
    }

这个函数的工作原理是这样的。isWordFoundInBaseWord函数接受一个String参数userGuessWord,并返回一个Bool值,指示userGuessWord是否可以通过从baseWord字符串中删除字母来创建。

函数首先创建一个comparisonWord变量,其值等于baseWordcomparisonWord变量用于跟踪baseWord中尚未与userGuessWord匹配的剩余字母。

接下来,函数使用StringallSatisfy方法来检查userGuessWord中的所有字母是否都可以在comparisonWord中找到。allSatisfy方法遍历字符串中的每个字符,如果传递给它的闭包对所有字符都返回true,则返回true

闭包接受一个参数字母,代表正在处理的userGuessWord中的当前字母。它首先使用firstIndex(of:)方法在comparisonWord中查找字母的索引。如果字母在comparisonWord中未找到,闭包立即返回false,这将使整个allSatisfy调用返回false

如果在comparisonWord中找到字母,闭包使用remove(at:)方法从comparisonWord中移除该字母。最后,闭包返回true

如果allSatisfy调用返回true,则函数返回true,表示userGuessWord可以通过从baseWord中删除字母来创建。如果allSatisfy返回false,则函数返回false

这种逻辑一开始可能有点难以理解,但它基本上是查看用户单词中的每个字母,并将其与baseWord进行比较。任何不在baseWord中的字母都会导致方法返回false,这意味着用户的单词是不正确的。

现在我们已经编写了这个方法,让我们来使用它。我们需要在addWordToList函数中调用这个方法,所以将此代码放在if语句的闭合括号之后:

func addWordToList() {
        let usersWord = 
          userEnteredWord.trimmingCharacters(in: 
          .whitespacesAndNewlines).lowercased()
        //is the word a duplicate
        if !isWordDuplicate(word: usersWord) {
        displayErrorMessage(messageTitle: "You already used
          this word", description: "Keep looking!")
        return
        }
   //is the word possible given your base word letters 
          to work with?
        guard isWordFoundInBaseWord(userGuessWord:
          usersWord) else {
            displayErrorMessage(messageTitle: "This word is
            not possible", description: "Create only words
            from the letters in the given word")
            return
        }
            userEnteredWordsArray.insert(usersWord, at: 0)
            userEnteredWord = ""        
        }

我们在这里做的是调用isWordFoundInBaseWord函数,并将userGuessedWord传递给它。该函数检查单词是否可以创建——如果不能,我们将使用displayErrorMessage方法(再次提醒,警告消息将稍后创建)向用户显示警告信息。

让我们试试这个。回到ContentView并运行程序。你会看到,除非那些字母也出现在baseWord中,否则你不能输入一个单词:

图 12.5:使用基本单词的字母检查单词是否可能

图 12.5:使用基本单词的字母检查单词是否可能

在这里,我将set放入列表中,因为可以从sent中创建它,因为isWordFoundInBaseWord函数中没有n,它正在完美地工作。

检查用户输入的单词是否是真实单词

我们还需要进行一个最后的检查,那就是查看单词是否是词典中的实际单词。这个检查很重要,因为用户可能会在baseWord中重新排列字母,自己造一个单词并输入到列表中。我们想要防止这种情况,并检查每个单词是否与实际的词典相匹配。为此,我们可以使用UITextChecker类。这个类有我们可以用来检查单词是否是词典中实际单词的方法和属性,而且特别好的是,它还可以与西班牙语和意大利语的拼写错误和真实性一起工作。

因此,让我们进入DataModel类,并在isWordInDictionary函数中添加以下代码:

 func isWordInDictionary(word: String) -> Bool {
    return UITextChecker().rangeOfMisspelledWord(in: word,
      range: NSRange(location: 0, length:
      word.utf16.count), startingAt: 0, wrap: false,
      language: "en").isNotFound
    }

我们在这里会得到一个错误,因为我正在添加这个isNotFound属性。所以,让我们先修复这个错误,然后再来看代码在做什么。在类的闭合括号外,添加以下扩展:

extension NSRange {
    var isNotFound: Bool {
        return location == NSNotFound
    }
}

回过头来看,isWordInDictionary函数检查给定的单词是否存在于英语词典中。它是通过创建一个UITextChecker实例并在其上调用rangeOfMisspelledWord方法来做到这一点的。此方法接受几个参数:

  • word:要检查拼写的单词

  • range:要检查的单词的范围,指定为NSRange

  • startingAt:要检查的单词在指定范围内的起始位置

  • wrap:一个布尔值,指示在检查拼写错误的单词时是否将范围环绕到范围的开始处

  • language:用于拼写检查的语言 – 在这种情况下,为"en"表示英语

然后,该方法返回一个NSRange,指示找到的第一个拼写错误的单词的范围。如果没有找到拼写错误的单词,则返回范围的location属性被设置为NSNotFound

isNotFound计算属性是NSRange的一个扩展,如果location属性等于NSNotFound,则返回true,否则返回false。这使得代码更易读,并允许我们用isNotFound代替在函数中比较NSNotFound的位置。

总结来说,该函数创建了一个 UITextChecker 类的实例。UITextChecker 会检查其字典中是否有任何拼写错误,以确保单词是真实的——如果它在字典中,则返回 true,并将单词添加到用户的列表中;否则,返回 false,并向用户显示一条消息,说明这不是字典中的实际单词。

现在函数已经完成,让我们来使用它。在 addWordToList 函数中上一个 guard 语句的闭合大括号之后直接添加以下代码:

 //is the word spelled correctly and a real word in the
   chosen language? - only real words allowed
    guard isWordInDictionary(word: usersWord) else {
        displayErrorMessage(messageTitle: "This is not a 
          valid word", description: "Use only real words")
        return
    }

在放置好代码后,再次运行应用程序,并使用 baseWord 中提供的字母创建自己的单词:

图 12.6:检查单词是否在字典中

图 12.6:检查单词是否在字典中

这里,我使用 baseWord 中的字母创建了一个单词——tesh——然而,我不允许将这个单词添加到列表中,因为英语中没有这样的单词,所以我们知道该函数正在正常工作。

这样就真的完成了应用程序的所有单词检查功能;我们将为用户单词未通过任何检查时添加弹出警报。

让我们继续构建 UI。我们将在顶部添加一个文本字符串,这将作为应用程序的名称,我们可以在一个单独的文件中对其进行样式化。

创建带有信息按钮的 HeaderView

让我们继续构建 UI 并添加一个标题,这将是我们应用程序的名称。我们可以在一个单独的文件中创建这个标题,这样我们就可以保持 ContentView 的整洁。

HeaderView 的目的是双重的——设置应用程序的标题,并添加一个打开用户设置页面的 info 按钮。

按下 Command + N,选择一个 HeaderView。然后,在 HeaderView 结构体中添加一个 Binding 属性:

@Binding var showSettings: Bool
Now let's update the Previews struct to satisfy Xcode and so we can build cleanly again:
HeaderView(showSettings: .constant(false))
        .previewLayout(.fixed(width: 375, height: 80))

接下来,在 body 属性内部添加以下代码:

   ZStack {
          Image("title").resizable()
              .frame(width: 250, height: 50)
              .shadow(color: .black, radius: 1, x: 1, y: 1)

          //info button
          Button(action: {
          }){
              Image(systemName: "info.circle")
                  .font(.system(size: 30, weight: .medium))
                  .padding(.top, 10)
                  .accentColor(Color.black)
          }.offset(x: 160)
      }

首先,我们添加了一个 ZStack 来容纳两个视图,一个显示应用程序标题的图像,以及一个我们将设置以将用户带到设置页面(在那里他们可以选择在游戏中使用的另一种语言)的按钮。还添加了图像上的 shadow 修饰符,以帮助使其更加突出。

在按钮闭合内部,我们使用系统信息圆圈图标,然后在上边添加一些顶部填充以使按钮垂直对齐图像,然后使用 accent 修饰符将按钮染成黑色。之后,我们将按钮向右偏移 150 点,使其位于屏幕右侧的图像旁边。

当按下 info 按钮时,将打开一个设置页面,为用户提供三种游戏语言选项。将使用 showSettings 属性来打开 SettingsView,但我们还没有 SettingsView,所以让我们创建一个。按下 Command + N,选择 SettingsView。就这样,我们现在有了 SettingsView。让我们回到 SettingsView

在按钮内部,我们需要切换 showSettings 状态变量,因此需要在按钮体中添加以下代码行:

showSettings.toggle()

最后,为了在另一个视图上打开一个表单,我们需要在按钮上调用 sheet 修饰符,因此将代码添加到按钮的闭合括号末尾:

 Button(action: {
              self.showSettings.toggle()
          }){
              Image(systemName: "info.circle")
                  .font(.system(size: 30, weight: .medium))
                  .padding(.top, 10)
                  .padding(.horizontal, 10)
                  .accentColor(Color.black)
          }.offset(x: 160)
           .sheet(isPresented: $showSettings) {
              //show the settings view
              SettingsView()
          }

在 SwiftUI 中,sheet 修饰符用于打开一个表单,它是一个从屏幕底部滑上打开的另一个视图。表单是通过 isPresented 参数触发的——当 isPresented 的值变为 true 时,sheet 修饰符体中的代码将执行。在 sheet 修饰符的闭包中,有一个对 SettingsView 的调用;这是覆盖 ContentView 的视图(表单)。要关闭表单,用户只需用手指将其滑回即可。

要看到这个功能在实际中的效果,我们需要在 ContentView 中添加两行额外的代码。首先,进入该文件,并在 ContentView 的顶部添加一个可以连接到 HeaderViewBinding 变量的 State 属性。将这个 State 变量添加到 ContentView 中上一个属性之后:

@State var showSettings: Bool = false

我将其命名为 showSettings,与它从 HeaderView 绑定的变量同名。

现在,我们只需要在 VStack 的顶部添加对 HeaderView 的调用,以使 info 按钮正常工作。在 VStack 的顶部添加以下代码行:

HeaderView(showSettings: $showSettings)

这样,我们就需要所有内容来使 SettingsView 正常工作。试试看——在 ContentView 中按信息按钮,SettingsView 就会打开:

图 12.7:SettingsView

图 12.7:SettingsView

要关闭视图,只需在打开的视图中向下滑动。这种“滑动关闭”的行为是自动嵌入到 sheet 修饰符中的,因此我们不需要实现它。

因此,SettingView 正在正常工作,尽管它现在除了打开并显示用户可以点击以设置他们想要玩的单词大小的 PickerView 外,没有做太多。

创建 PickerView

PickerView 是一个向用户展示各种选项的视图。选择器可以是带有下拉列表的单个按钮,一个带有多个按钮的分段列表,或者用户可以旋转以选择选项的轮盘。

在风格方面,这三个选择器有两个主要区别。第一个是它们在你的应用中是如何出现的。第二个是用户可用的选项数量——轮盘可以为用户提供许多许多选项,而按钮选择器或分段控制器选择器则受屏幕空间大小的限制。

由于我们只向用户提供三个选项,即七、八或九个字母的单词选项,我们将使用分段控制器。

添加 PickerView

让我们从添加一个新的 PickerView 开始。接下来,让我们在结构体的顶部添加 DataModel 类的实例,这样我们就可以访问数据属性:

@ObservedObject var appData = DataModel()

然后添加一个 VStack,并在其中添加我们位于 Assets 目录中的 topBar 图片,这将有助于为选择器提供框架:

VStack(alignment: .center, spacing: 10) {
          //bar
          Image("topBar").resizable()
              .frame(width: 280, height: 8)
              .padding(.bottom, 10)
              .shadow(color: .black, radius: 1, x: 1, y: 1)
}

在代码中,我们添加了topBar图像,并在其周围添加了一些填充和阴影,以帮助它突出显示在背景(即将添加)上。然后,我们在VStack上使用10点的中心间距来保持视图之间的空间。

现在,在刚刚添加的代码最后一行下方添加Picker控件:

Picker("", selection: $appData.selectedSegment) {
              Text("7 Letter").tag(1)
              Text("8 Letter").tag(2)
              Text("9 Letter").tag(3)

          }

这段代码添加了选择器并使用DataModel中的selectedSegment属性,该属性用于跟踪用户选择了选择器的哪个部分。选择器被设置为三个不同的标题,我们使用tag修饰符来区分选择器控件上的哪个标题。现在,用户可以在789个字母之间进行选择。

这是默认按钮外观,当按下时为用户提供选项:

图 12.8:按钮选择器

图 12.8:按钮选择器

现在,让我们看看我们如何将选择器样式调整为更适合我们游戏的形式。

PickerView 样式

正如我提到的,我觉得分段控件看起来更好,所以让我们将样式更改为那个选择器选项。在PickerView的闭合括号之后添加以下代码:

      .pickerStyle(SegmentedPickerStyle())
      .background(RoundedRectangle(cornerRadius: 8)
      .stroke(Color.black, lineWidth: 4).shadow(color: 
        Color.black, radius: 8, x: 0, y: 0))
      .cornerRadius(8)
      .padding(.horizontal, 50.0)
      .padding(.bottom, 10)

我们将选择器设置为分段样式,并添加了一个边角半径为8点的圆角矩形,在选择器控件周围有黑色描边,以给它一个漂亮的边框。然后,我们用一些阴影和填充来完成它,这就是它的样子:

图 12.9:完成的 PickerView

图 12.9:完成的 PickerView

PickerView已完成,但为了完成PickerView文件中的工作,让我们在场景中添加一个bottomBar图像。在shadow修饰符之后,添加以下内容:

Image("bottomBar").resizable()
        .frame(width: 280, height: 8)
        .padding(.bottom, 10)
        .shadow(color: .black, radius: 1, x: 1, y: 1)

这只是像topBar一样设置bottomBar,并帮助在 UI 中构建PickerView

图 12.10:已添加底部栏图像

图 12.10:已添加底部栏图像

让我们回到ContentView,在那里我们需要调用PickerView来将其引入场景。在HeaderView代码下方添加以下内容:

          //MARK: - PICKER
        PickerView()

现在我们有了可以提供用户选择的PickerView。它目前还没有做任何事情,因为我们还需要填写setWordLengthAndLanguage方法,所以让我们来做这件事。回到DataModel,在setWordLengthAndLanguage方法内部添加以下代码:

    //sets the word length and language for the game
    func setWordLengthAndLanguage() -> String {
        let language = ["English": "En", "Spanish": "ES",
          "Italian": "It"]
        let wordLength = [1: "7", 2: "8", 3: "9"]
        var dataFile = ""
        var selectedLanguage = ""
        if englishIsOn == true {
            selectedLanguage = "English"
        } else if spanishIsOn == true {
            selectedLanguage = "Spanish"
        } else if italianIsOn == true {
            selectedLanguage = "Italian"
        }
        if let languageCode = language[selectedLanguage],
          let lengthCode = wordLength[selectedSegment] {
        dataFile = 
          "\(lengthCode)LetterWords\(languageCode)"
          letterCount = "🇱🇷 \(lengthCode) letter word – 
            \(selectedLanguage) 🇱🇷"
        }
        return dataFile
    }

这就是setWordLengthAndLanguage函数的工作方式。它首先定义了两个字典,languagewordLength

  • language字典将不同语言的名称映射到它们的缩写。在这种情况下,"English"映射到"En""Spanish"映射到"ES""Italian"映射到"It"

  • wordLength字典将整数值映射到它们对应的单词长度。例如,1映射到"7"2映射到"8"3映射到"9"

接下来,函数检查selectedSegment变量的值。该变量包含一个整数,表示用户选择的单词长度(七个字母、八个字母或九个字母)。

根据selectedSegment的值,函数使用方括号表示法从wordLength字典中检索相应的单词长度,即wordLength[selectedSegment]。这给我们提供了单词长度作为字符串,例如789

函数随后检查englishIsOnspanishIsOnitalianIsOn变量的值。这些变量包含布尔值,表示用户是否选择了相应的语言。

如果这些语言变量中的任何一个为真,则函数使用相应的语言缩写(从language字典中检索)和单词长度(从wordLength字典中检索)来构造包含该语言和单词长度的单词数据文件的文件名。例如,如果用户选择了七个字母的英语,则文件名将是7LetterWordsEn

函数将letterCount变量的值设置为字符串,该字符串提供了所选语言和单词长度的描述,例如7 letter word - American English

最后,函数返回构造的文件名作为字符串。

为了让语言单词计数字符串在选择器控件中工作,我们需要在ContentView中添加一段代码。将以下代码直接添加到按钮的shadow修饰符之后:

//MARK: - WORD TO PLAY FROM
      VStack {
          //word letter count string
          Text("\(appData.letterCount)")
              .font(.system(size: 18, weight: .regular, 
                design: .serif))
              .foregroundColor(Color.white)
              .bold()
              .shadow(color: .black, radius: 1, x: 1, y: 1)

      }

在此代码中,我们添加了一个VStack,然后通过使用appData实例在Text视图中显示了letterCount属性。然后,我们应用了字体大小和粗细,设置了白色前景,使其加粗,并给Text视图添加了阴影。现在我想让你做的是,剪切显示wordToPlayFrom属性的Text视图,并将其粘贴到刚刚添加的VStack中,放在最末尾。这样就可以使两个文本视图水平排列。

现在,为了测试选择器并查看它显示字母单词字符串以及不同语言的单词,我们需要来到我们的数据文件,并将其中一个language变量设置为true;仅用于测试目的,我们将在设置页面中稍后切换这些变量。所以,在DataModel中,将spanishIsOn变量更改为true,如下所示:

@AppStorage ("spanishIsOn") var spanishIsOn: Bool = true

现在,进入ContentView并从选择器控件中选择你想要使用的单词长度。按下按钮,你将看到你选择的长度的一个西班牙语单词,以及显示所选语言标志和单词大小选择器的letterCount字符串:

图 12.11:选择游戏的单词大小和语言

图 12.11:选择游戏的单词大小和语言

您也可以通过将其中一个语言设置为true,而将其他设置为false来检查其他两种语言。另外,如果您没有将AppStorage变量之一设置为true,那么选择器将不会选择单词的长度;它将默认为八个字母的单词(当我们在下一章完成添加其余代码时,这种行为将自行解决)。

在测试完所有内容后,请确保将data变量设置回false

摘要

因此,我们已经到达了这一章节的结尾,并且完成了很多工作。

我们开始构建我们的游戏,允许用户输入单词来玩游戏,并添加检查来验证他们的选择。我们还添加了一个按钮,用户可以按下它来从包含数千个单词的文件中随机获取一个单词,一个弹出设置页面的按钮,一个选择控件,以及一个字母计数字符串。

接下来,我们将继续构建应用程序,具体来说,是构建用户界面,设置页面,使其包含用户可以选择的语言的三个按钮,并在用户输入的单词无效时添加错误信息。那么,让我们在下一章继续这个项目的第二部分。

第十三章:创建一个单词游戏(第二部分)

在我们项目的第一部分,我们组装了大部分界面并实现了游戏的大部分功能,所以我们目前有一个可以列出用户输入的所有单词的工作游戏。

让我们在这里继续项目的第二部分,完成设置屏幕,以便用户可以选择他们喜欢的语言,并添加更多元素到 UI 中,使其更加精致——例如,添加用户反馈。

在本章中,我们将涵盖以下主题:

  • 创建设置屏幕

  • 样式化 UI

  • 使用警报实现用户反馈

  • 添加页脚视图以显示更多信息

  • 添加触觉反馈和按钮声音

技术要求

您可以从 GitHub 上的Chapter 13文件夹下载资源和完善的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications

创建设置屏幕

现在我们能够设置单词长度,让我们组装设置视图,以便用户可以点击信息按钮并实际从那里更改语言。我们想要创建三个按钮——每个语言一个——以及一个第四个按钮,即完成按钮,用户可以使用它来完成他们的选择并关闭页面。

因此,在SettingsView内部,我们首先需要一个变量来访问DataModel。在SettingsView结构体的顶部添加此观察对象变量:

 @ObservedObject var appData = DataModel()

接下来,让我们在body属性中为这个页面添加一个标题,称为语言设置

 VStack {
        Text("Language Settings")
            .font(.title).bold()
            .padding(.top, 20)
        }

在这里,我们只是添加了一个带有少量填充和粗体字体的Text视图,所有这些都在VStack内部。

接下来,让我们在VStack内部添加一个Form视图和一个Section标题视图,以分组我们将要添加的按钮。将此代码放置在padding修饰符下方:

        Form {
            Section(header: Text("Select a language")) {
            }
        }

Form视图是一个常用的容器,用于将控件分组在一起,而Section标题将为每个分组添加一些标题文本。到目前为止,SettingsView看起来是这样的:

图 13.1:使用表单和部分标题视图来设置

图 13.1:使用表单和部分标题视图来设置SettingsView

接下来,我们将添加按钮让用户选择语言。

添加语言按钮

我们将要创建的按钮将带有木纹图像背景和一些应用到它们上的文本。让我们在单独的结构体中完成按钮样式。

在文件中的任何位置添加以下结构体,只要它不在SettingsView结构体内部(我通常在其他结构体上方创建其他结构体):

//style the button with a background and text
struct configureButton: View {
  var buttonText = ""
  var body: some View {
      ZStack {
          Image("background").resizable()
              .frame(width: 70, height: 35)
              .cornerRadius(10)
          Text(buttonText)
              .foregroundColor(.white)
              .shadow(color: .black, radius: 1, x: 1, y: 1)
      }
  }
}

我们调用configureButton结构体,并在其中创建一个string变量,该变量将保存按钮的文本。然后,我们使用资产库中的背景图像,设置其大小和cornerRadius值。之后,我们添加一个带有白色颜色和阴影的Text视图,所有这些都在ZStack内部完成,以便它们相互叠加。

让我们继续添加第一个按钮,这将允许用户以英语语言玩游戏。在 Section 标题大括号内添加以下代码:

var body: some View {
    VStack {
        Text("Language Settings")
            .font(.title).bold()
            .padding(.top, 20)
        Form {
            Section(header: Text("Select a language")) {
        VStack(alignment: .center, spacing: 10) {
            HStack {
                //english button
                Button(action: {

                }){
                    configureButton(buttonText: "English")
                }
                Spacer()

                //english flag
                Image("engFlag").resizable()
                    .border(Color.black, width: 1.5)
                    .frame(width: 50, height: 30)
            }
        }
      }
    }
  }
}

在这个代码块中,我们添加了一个居中对齐且间距为 10 点的 VStack。这将确保每个按钮之间垂直间距为 10 点。

除了创建一个用于选择英语语言的按钮外,我还想为所选国家的语言添加一个国旗。我们在 HStack 中这样做 – 按钮将在屏幕左侧,国旗将在右侧。接下来,我在按钮闭包中调用 configureButton 结构体,这将设置按钮为单词 English 并给它一个木纹背景图片。对于国旗,我在它周围添加了一个 1.5 点宽的小边框,使其边缘更加清晰,并且由于这与英语按钮相关联,我们将使用美国国旗图片。

现在你可以尝试一下;按下 ButtonStyle 协议来控制按钮按下时闪烁的内容。

让我们这样做。在 configureButton 结构体下方添加以下代码:

struct ButtonFlash: ButtonStyle {
    func makeBody(configuration: Configuration) -> some 
View {
    configuration.label
        .shadow(color: .black, radius: 2, x: 2, y: 2)
        .opacity(configuration.isPressed ? 0.3 : 1)
  }
}

这个结构体被称为 ButtonFlash,它使用了 ButtonStyle 协议。这是一个将交互行为和自定义外观应用于按钮的协议。ButtonStyle 协议要求我们使用 makeBody 方法来配置按钮标签的行为和外观。

在代码中,我们在 makeBody 函数中只调用了两个修饰符 – shadowopacityopacity 修饰符将检查 isPressed 属性以确定它是否为 true。如果是 true,这意味着按钮已被按下,因此我们可以将不透明度更改为 .3,这将使按钮稍微变暗。否则,如果按钮未被按下,按钮将保持完全不透明。

接下来我们只需要在按钮上调用 buttonFlash 结构体,就像这样:

      HStack {
                //english button
                Button(action: {

                }){
                    configureButton(buttonText: "English")
                }.buttonStyle(ButtonFlash())
                Spacer()

                //english flag
                Image("engFlag").resizable()
                    .border(Color.black, width: 1.5)
                    .frame(width: 50, height: 30)
            }

buttonStyle 协议会自动为我们创建一个新的修饰符,也使用 buttonStyle,我们可以通过使用点语法并传入我们新结构体的名称 ButtonFlash 来访问它。

现在,按钮有了新的外观。它周围有阴影,给人一种 3D 的感觉:

图 13.2:英语语言按钮

图 13.2:英语语言按钮

当你按下按钮时,修饰符将帮助使其看起来像实际上被按下。它看起来就像按钮被按下,而不是整行。

现在,按钮的配置和样式已经完成,让我们添加两个其他语言的按钮。在 HStack 结束括号后添加此代码:

          Divider()
            HStack {
                //English button
                Button(action: {

                }){
                    configureButton(buttonText: "Spanish")
                }.buttonStyle(ButtonFlash())
                Spacer()
                //Spanish flag
                Image("esFlag").resizable()
                    .border(Color.black, width: 1.5)
                    .frame(width: 50, height: 30)
            }
            Divider()

            HStack {
                //Italian button
                Button(action: {

                }){
                    configureButton(buttonText: "Italian")
                }.buttonStyle(ButtonFlash())
                Spacer()
                //italian flag
                Image("itFlag").resizable()
                    .border(Color.black, width: 1.5)
                    .frame(width: 50, height: 30)
            }

代码从 Divider 开始,这是一个非常细的线,用于区分第一个按钮和第二个按钮。然后,我们使用另一个 HStack,类似于我们刚刚为 Divider 添加的,然后创建我们的 意大利 按钮。

现在,按钮已经完成,但它们在被按下时除了看起来不错之外没有其他作用。我们需要使它们具有功能,并实际上选择单个语言。这很简单——我们只需要访问我们的数据模型并更改语言属性为正确的值,即truefalse

让我们从英语按钮开始。在按钮的主体内部添加以下代码:

                    appData.englishIsOn = true
                    appData.spanishIsOn = false
                    appData.italianIsOn = false

我们在这里所做的是访问DataModel并将englishIsOnProperty更改为true,因为false。现在,如果按下英语按钮,将只使用那个数据文件来生成一个新的随机单词。

让我们在西班牙语按钮中添加类似的逻辑:

                    appData.englishIsOn = false
                    appData.spanishIsOn = true
                    appData.italianIsOn = false

对于false,以及西班牙属性设置为true

最后,让我们将以下代码添加到意大利语按钮中:

                    appData.englishIsOn = false
                    appData.spanishIsOn = false
                    appData.italianIsOn = true

现在,所有按钮都已完成,并且它们将实现用户选择的语言。

添加勾选标记

然而,我们可以在这里稍微改进一下设计。目前,当用户按下语言按钮时,没有指示按钮选择了什么。我们是否可以在所选语言的旗帜旁边添加一个勾选标记?

让我们添加另一个结构体,用于为按钮添加勾选标记。在ButtonFlash结构体下添加以下结构体:

//add a checkmark
struct addCheckmark: View {
    var isLanguageOn: Bool = false
    var body: some View {
        VStack{
            Image(systemName: "checkmark.circle")
                .imageScale(.small).foregroundColor(.green)
                .font(Font.largeTitle.weight(.regular))
                .opacity(isLanguageOn ? 1.0 : 0)
        }
    }
}

我将这个结构体命名为addCheckmark,然后创建一个布尔值来检查哪个语言已被选中。在结构体的主体内部,我们创建一个小勾选标记,将其涂成绿色,并根据isLanguageOn属性是否为true来设置勾选标记的透明度,要么完全可见,要么隐藏。

现在,我们可以在每个HStack内部调用这个结构体,这样勾选标记就会放置在旗帜的一侧。为了简洁起见,我将勾选标记代码添加到第一个HStack中,但您还需要将勾选标记代码添加到所有三个HStack实例中,位置与我这里使用的位置相同:

  VStack(alignment: .center, spacing: 10) {
            HStack {
                //english button
                Button(action: {
                    appData.englishIsOn = true
                    appData.spanishIsOn = false
                    appData.italianIsOn = false
                }){
                    configureButton(buttonText: "English")
                }.buttonStyle(ButtonFlash())
                Spacer()
                //english - checkmark appears when 
                  englishIsOn is true
               addCheckmark(isLanguageOn: 
                  appData.englishIsOn)
                //english flag
                Image("engFlag").resizable()
                    .border(Color.black, width: 1.5)
                    .frame(width: 50, height: 30)
            }
            Divider()

代码调用addCheckmark结构体,并在按下时为每个按钮添加勾选标记。

如果您现在从ContentView运行应用程序,您将看到您可以按下信息按钮以打开设置屏幕,选择一种语言,然后通过滑动它来关闭屏幕。

图 13.3:语言按钮完成

图 13.3:语言按钮完成

此外,如果您停止应用程序并再次运行它,请注意您的选择将保持不变,因为我们通过在数据模型中使用@AppStorage属性包装器将设置内部存储在用户默认设置中。

我们差不多完成了设置视图,但我还想在这里添加一个额外的修改——我们是否可以给用户另一种关闭设置屏幕的方式,通过添加一个关闭按钮,而不是仅仅从顶部滑动下来?

添加一个关闭按钮

对于关闭按钮,我们需要一个可以访问应用环境的属性。环境是应用为我们自动生成的一部分,我们可以在这里访问系统级设置,例如颜色方案或布局方向。

就在这个环境中,我们需要关闭一个屏幕,为此,我们首先需要使用@Environment包装器创建一个变量。在SettingsView文件顶部的ObservedObject属性下方添加以下内容:

//dismiss the SettingsView
    @Environment(\.presentationMode) var presentationMode

现在,我们将使用这个属性来关闭视图。在文件中最后一个HStack的闭合括号之后添加这个最后的HStack

          //MARK: - DISMISS BUTTON
             HStack(alignment: .center) {
                Spacer()

                Button(action: {
                    presentationMode.wrappedValue.dismiss()
                }){
                    HStack {
                        Image(systemName: "checkmark")
                        Text("Done")
                            .padding(.horizontal, 5)
                    }.padding(8)
                        .shadow(color: .black, radius: 1, 
                          x: 1, y: 1)
                }.foregroundColor(Color.white)
                    .background(Color.green)
                     .cornerRadius(20).shadow(color: 
                       .black, radius: 1, x: 1, y: 1)
                    .buttonStyle(ButtonFlash())

               Spacer()
            }

首先,我们在代码的顶部和底部使用了两个Spacer实例来将按钮对齐在HStack的中心。

在按钮内部,wrappedValue属性的presentationMode属性将访问设置屏幕数据,然后调用dismiss函数将关闭设置屏幕。

按钮被样式化为勾选标记,文字说ButtonFlash函数,使其像其他按钮一样闪烁。

现在,从ContentView运行应用,你可以看到关闭按钮工作得非常完美:

图 13.4:完成按钮

图 13.4:完成按钮

这就完成了SettingsView。让我们回到ContentView并继续样式化 UI。

UI 样式化

ContentView中,让我们为用户添加一些更多功能;我们将添加一个背景,一些游戏动画,以及单词计数和语言显示标签。添加一些额外的样式将有助于使 UI 生动起来。

添加背景

我们将首先为场景添加一个背景。我们目前有一个标题和一些带有木质外观样式的按钮,所以让我们继续木质主题,并使用木质图像作为背景。在VStack闭合括号之后添加以下代码:

    .background(Image("background").resizable()
          .edgesIgnoringSafeArea([.all])
          .aspectRatio(contentMode: .fill)
          .frame(width: 500, height: 800))

此代码将木质图像设置为背景,并使用edgesIgnoringSafeAreas修饰符将背景拉伸以填充屏幕。同时,将纵横比设置为fill,并设置一个框架来调整此背景图像的大小:

图 13.5:木质背景

图 13.5:木质背景

背景看起来不错,但它确实引入了一个小问题——我们需要更改wordToPlayFrom属性的颜色和大小,因为它的黑色使其在木质背景上难以看清。我们现在就来做这个调整。在按钮代码之后,我们可以样式化这个Text视图,使其看起来更好:

     //MARK: - WORD TO PLAY FROM
      Text("\(wordToPlayFrom)")
          .font(.custom("HelveticaNeue-Medium", size: 38))
          .foregroundColor(.white)
          .shadow(color: .black, radius: 1, x: 1, y: 1)

代码添加了一个大小为38点的自定义Helvetica字体,同时将文字颜色改为白色并添加阴影,使其更加突出。这就是它的样子:

图 13.6:样式化的基础单词

图 13.6:样式化的基础单词

现在看起来清晰多了!

为游戏单词添加动画

我们是否也可以给新单词添加一些动画呢?我们可以使用scaleEffect修饰符在单词进入视图时水平翻转它。

让我们先在ContentView的其他属性之后添加一个属性来跟踪这个动画:

    @State private var horizontalFLip = false

现在,在WORD TO PLAY FROM祈使句中,并在shadow修饰符之后,添加以下代码:

            .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
            .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
            .animation(.spring(dampingFraction:0.8),value: 
              horizontalFLip)

通过两次调用scaleEffect修饰符,每次调用都会将文本水平翻转 180°,这样当它进入屏幕时,水平方向上会完成一个完整的旋转。同时,它还会应用一个spring动画,使其略微增长和收缩;你可以通过设置dampingFraction参数的值来控制增长和收缩的程度。

此外,我们希望动画在按钮按下时开始,因为那时新的单词会出现在屏幕上,所以让我们在按钮体内切换horizontalFlip布尔值:

//MARK: - BUTTON
      Button(action: {
          appData.getRandomWord()
          wordToPlayFrom = appData.baseWord
          horizontalFLip.toggle()
      }){

在我们尝试之前,让我们对新的输入单词进行一些进一步的扩展——我们将在新单词的两侧添加指向的手,并添加一个spring动画。让我们将显示wordToPlayFromText视图放入HStack中,如下所示:

HStack {
      Text(wordToPlayFrom)
          .font(.custom("HelveticaNeue-Medium", size: 38))
          .foregroundColor(.white)
          .shadow(color: .black, radius: 1, x: 1, y: 1)
          .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
          .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
          .animation(.spring(dampingFraction:0.8),value: 
            horizontalFLip)
  }

接下来,让我们添加一些指向要播放的单词的手部图像。第一个图像是系统图像hand.point.right,它位于HStack中,正好在Text视图代码之上,下一个图像是hand.point.left图像,它被放置在Text视图代码下方,同样,所有这些都位于我们刚刚添加的HStack视图中:

//MARK: - WORD TO PLAY FROM
 HStack () {
      //right hand image
      Image(systemName: "hand.point.right")
          .foregroundColor(.black)
          .font(.system(size: 30))
.animation(.spring(dampingFraction:0.2),value: 
            horizontalFLip)

      Text(wordToPlayFrom)
          .font(.custom("HelveticaNeue-Medium", size: 38))
          .foregroundColor(.white)
          .shadow(color: .black, radius: 1, x: 1, y: 1)
          .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
          .scaleEffect(x: horizontalFLip ? -1 : 1, y: 1)
          .animation(.spring(dampingFraction:0.8),value: 
            horizontalFLip)

      //left hand image
      Image(systemName:"hand.point.left")
         .foregroundColor(.black)
         .font(.system(size: 30))
         .padding(-4)
.animation(.spring(dampingFraction:0.2),value: 
           horizontalFLip)
  }

代码从系统图像开始,该图像是一只指向右边的手,将前景色设置为黑色,并使用font修饰符设置图像大小。然后,我们向其添加一个阻尼系数为.4的弹簧动画(再次强调,值越大,弹簧动画越不明显)。

然后,我们转到左侧图像,并执行相同的事情,设置其大小和颜色,以及一点填充来将其定位得更左边。此外,spring动画中的value参数将是horizontalFlip布尔属性,并在按钮体内切换,这就是动画被触发的方式。

现在,再次运行应用程序——你会看到每次你点击按钮添加新单词时,单词不仅会水平翻转,拉伸和收缩,而且两只手也会随着单词进入屏幕而弹入和弹出:

图 13.7:动画基础单词,加上手部图像

图 13.7:动画基础单词,加上手部图像

在我们继续进行更多增强之前,让我们先解决一个问题,即letterCount字符串仅在按钮按下时才出现在 UI 上。最好始终将其显示在 UI 上,并在按钮按下时更新它。我们可以通过一行代码来修复这个问题,即在ContentView中调用getRandomWord函数。让我们在VStack的底部添加这个调用,就在frame修饰符的背景之后:

.onAppear (perform: appData.getRandomWord)

onAppear 修饰符将调用 getRandomWord 函数并填充 letterCount 字符串,使其始终显示在屏幕上:

图 13.8:显示单词长度和旗帜字符串

图 13.8:显示单词长度和旗帜字符串

接下来,我们需要实现警报系统,以便向用户提供有关任何不被接受进入列表的单词的反馈。

使用警报实现用户反馈

用户可以获取一个随机单词,并尝试用他们选择的任何语言从这个单词中制作一些单词,但我们已经设置了一些检查,以阻止这些单词进入列表。现在,我们需要让用户知道为什么某个单词不可接受,使用 SwiftUI 警报。

让我们回到 DataModel 中,用适当的代码填充 displayErrorMessage 函数占位符以获取消息警报。将以下代码直接添加到方法中:

//error message
    func displayErrorMessage(messageTitle: String, 
      description: String) {
        errorTitle = messageTitle
        errorDescription = description
        errorMessageIsShown = true
    }

代码将 messageTitle 字符串分配给 errorTitle 属性。然后,它将描述字符串分配给 errorDescription 属性。之后,将 errorMessagesIsShown 布尔值设置为 true,因为此时函数已被触发,我们需要将此属性设置为 true 以启动警报消息。

接下来,需要在正确的地方调用 displayErrorMessage 函数。如果您还记得我们之前设置 addWordToListist 方法时,在数据模型中添加了方法占位符,我们还调用了 displayErrorMessage 方法。以下是包含对 displayErrorMessage 的调用的完整 addWordToList 方法,因此我们那里没有需要做的事情:

    func addWordToList() {
    let usersWord = 
      userEnteredWord.lowercased().trimmingCharacters(in: 
      .whitespacesAndNewlines)

    //guard against single letter words – they are too easy
    guard usersWord.count > 1 else {
        return
    }
    //is the word a duplicate
    guard isWordDuplicate(word: usersWord) else {
        displayErrorMessage(messageTitle: "You already used 
          this word", description: "Keep looking!")
        return
    }
    //is the word possible given your base word letters to 
      work with?
    guard isWordFoundInBaseWord(userGuessWord: usersWord)
      else {
displayErrorMessage(messageTitle: "This word is not 
possible", description: "Create only words from 
          the letters in the given word")
        return
    }
    //is the word a real word in the dictionary? - only 
      real words are allowed
    guard isWordInDictionary(word: usersWord) else {
        displayErrorMessage(messageTitle: "This is not a 
          valid word", description: "Use only real words")
        return
    }
    userEnteredWordsArray.insert(usersWord, at: 0)
    userEnteredWord = ""
  }

这里简要回顾一下 addNewWord 函数的工作原理。

首先,单词被转换为小写 – 这是因为所有单词文件都是小写的,这是我们比较单词的方式。然后,我们移除他们可能输入到文本字段中的任何空白字符。

下一行代码防止单字母单词 – 同样,任何给定语言中只有少数几个这样的单词,在这里包含它们是没有意义的。

然后,我们开始检查用户的单词是否是重复的单词。如果是,这意味着他们已经将这个单词添加到列表中,所以 displayErrorMessage 函数将显示一个错误消息 You already used this word,以及一条说明 继续寻找

下一个 guard 语句检查根据用户正在使用的字母,单词是否可能制作。如果不可能,将调用 displayErrorMessage 函数并显示 This word is not possible 错误,以及一条说明 只能从 给定单词 中的字母创建单词 的指令。

接下来,如果单词在所选语言中不是真实单词,将调用 displayErrorMessage 函数,向用户显示一个警告,说明 这不是一个有效的单词,并附带说明 请只使用 真实单词

最后,在用户的单词经过处理并通过所有这些检查后,它被输入到 userEnteredWordsArray 中。记住,数组元素是索引的,从 0 开始,这是数组的开始。索引是我们访问数组中不同元素的方式,所以在 insert(at:) 方法中,我们传递“0”值,这意味着将用户的单词插入到列表的顶部,即列表的顶端。这样,随着用户输入单词,它们总是会放在列表的顶部,以便在屏幕上可见,如果有很多单词,用户可以向上滚动列表来查看它们。

最后一行代码将用户输入的单词重置为空字符串,以便再次开始过程。

现在我们已经设置了所有错误警告,我们只需要添加 alert 修饰符来使其功能化。我们可以在 ContentView 中的 onAppear 修饰符之后直接添加它,如下所示:

     //add the alert popup
    .alert(isPresented: $appData.errorMessageIsShown) {
        Alert(title: Text(appData.errorTitle), message: 
          Text(appData.errorDescription), dismissButton: 
          .default(Text("OK")))
    }

alert 修饰符有一个名为 isPresented 的参数,当 errorMessageIsShown 布尔变量变为 true 时,它将显示一个警告消息。当它变为 true 时,警告体内的代码(即错误标题和描述)将被执行。

现在,试一试。输入列表中已经存在的单词,你应该会看到一个弹出窗口,如下所示:

图 13.9:重复单词的警告消息

图 13.9:重复单词的警告消息

如果你输入的单词在给定的字母中不可能,你会看到这个警告:

图 13.10:使用给定单词不可能的单词的警告消息

图 13.10:使用给定单词不可能的单词的警告消息

最后,如果你输入的单词在所选语言的字典中不存在,你会看到这个警告:

图 13.11:非真实单词的警告消息

图 13.11:非真实单词的警告消息

现在,我们对用户的单词进行了三种不同的检查,只是为了给他们反馈,让他们知道他们哪里做错了。用户可以按下 OK 按钮来关闭警告并继续游戏。

现在,让我们继续进行 UI,并添加一个页脚视图来显示有关用户在游戏中的进度的一些更多信息。

添加页脚视图以显示更多信息

我们将要添加的页脚视图将包含两条信息——首先,用户迄今为止找到了多少单词,其次,每个找到的单词的平均字母数。

要做到这一点,创建一个新的 FooterView。这将包含我们需要显示该信息的 Text 视图。

现在,让我们开始工作并添加一些代码。在FooterView结构体内部添加以下属性:

struct FooterView: View {
    //MARK: - PROPERTIES
    @ObservedObject var appData = DataModel()
    @Binding var userEnteredWordsArray: [String]

    var foundWords: Double {
        let wordCount = userEnteredWordsArray.count
        //if theres no words in the array, return 0
        if wordCount == 0 {
            return 0
        }
        var letterAverage = 0
        //get a total of all the letters in each word
        for letterCount in userEnteredWordsArray {
            letterAverage += letterCount.count
        }
        return Double(letterAverage / wordCount)
    }
•••••••

如您所回忆的,当我们向结构体添加Binding变量时,我们需要将其包含在Previews结构体中;否则,我们将得到一个 Xcode 错误。按照以下方式修改Previews结构体以使 Xcode 满意:

struct FooterView_Previews: PreviewProvider {
    static var previews: some View {
      FooterView( userEnteredWordsArray: .constant(["0"]))
          .previewLayout(.fixed(width: 350, height: 125))
  }

好的,我们现在没有错误了,所以让我们回到FooterView结构体并回顾我们刚刚添加的代码。

有一个userEnteredWordsArray属性,我们将将其绑定到ContentView结构体。

接下来,我们有一个名为foundWords的计算属性。计算属性是一个具有主体并在访问属性时在其主体中运行代码的属性。foundWords属性通过使用count属性显示用户迄今为止找到的单词数量,该属性将返回userEnteredWordsArray中的元素数量。如果没有单词在数组中,它将返回0

接下来,在foundWords计算属性内部,我们创建了一个名为totalLetters的变量,并使用它来存储用户单词中的所有字母。为了获取所有这些字母,我们使用for in循环遍历数组,将用户输入的每个单词中的字母数量存储在totalLetters变量中。

最后,foundWords计算属性返回每个单词的所有字母除以输入的单词数量,从而返回每个单词的平均字母数量。

现在,让我们进入body属性并开始设计两个将显示此信息的文本视图。添加以下代码:

VStack {
    HStack(spacing: 80) {
        ZStack {
            Image("background").resizable()
            .frame(width: 80, height: 50)
            .clipShape(Capsule())
            .shadow(color: .black, radius: 1, x: 1, y: 1)
            .shadow(color: .black, radius: 1, x: -1, y: -1)

            Text("\(userEnteredWordsArray.count)")
            .frame(width: 50, height: 20)
            .font(.system(size: 25))
            .padding(20)
            .foregroundColor(.white)
            .font(.system(size: 80))

            Image("foundWords").resizable()
            .aspectRatio(contentMode: .fill).frame(width: 
              100, height: 70)
            .shadow(color: .black, radius: 1, x: 1, y: 1)
            .offset(y: 40)
            .padding(.horizontal, -10)
            .padding(.bottom, -10)
         }
     }
         }

我们首先要做的是为文本放置的背景进行样式设计。我们使用来自资产目录的background图片,并将其大小设置为宽度为80点,高度为50点。然后,我们使用clipShape修饰符将矩形转换为胶囊形状,并为图片添加一些阴影。请注意,我们在这里使用了两次shadow修饰符——这比单次调用提供了更明确的边框。

接下来,添加文本,并通过在textField上调用count属性,我们可以显示用户迄今为止找到的单词数量。然后,我们设置字体大小并添加一些填充以及前景色为白色。

我们将所有图片都放在三个堆栈中:

  • 首先,在VStack中垂直排列所有视图并相应地对齐。

  • 然后,在HStack中,我们将两个textField实例水平并排放置。

  • 最后,使用ZStack以便我们可以将文本直接放置在背景图片上。

最后,我们添加foundWords图片,调整其大小,并添加一些阴影和一些填充,以便我们可以将其从文本向下偏移。

现在,我们可以添加其他文本,这将显示每个单词的平均字母数量。在ZStack的闭合括号之后添加以下代码:

ZStack {
        Image("background").resizable()
            .frame(width: 80, height: 50)
            .clipShape(Capsule())
            .shadow(color: .black, radius: 1, x: 1, y: 1)
            .shadow(color: .black, radius: 1, x: -1, y: -1)

        Text("\(foundWords, specifier: "%.0f")")
            .frame(width: 50, height: 20)
            .font(.system(size: 25))
            .padding(20)
            .foregroundColor(Color.white)
            .font(.system(size: 80))

        Image("letterAverage").resizable()
            .aspectRatio(contentMode: .fill)
            .shadow(color: .black, radius: 1, x: 1, y: 1)
            .frame(width: 100, height: 70)
            .offset(y: 40)
            .padding(.bottom, -10)
    }

这段代码复制了我们之前添加的Text视图,但这次我们显示的是foundWords计算属性,它将显示foundWords数组中的字母平均数,并且使用格式说明符,将文本视图格式化为两位小数。

现在,我们可以在ContentView中调用FooterView。让我们回到那里,在主VStack代码的末尾添加对FooterView的调用:

//MARK: - FOOTER VIEW
    FooterView(userEnteredWordsArray: 
      $appData.userEnteredWordsArray)

现在,请继续尝试这个应用。你输入列表中的每一个单词都会在屏幕的左下角进行计数,而你输入的每个单词的平均字母数将在屏幕的右下角显示:

图 13.12:显示找到的单词和字母平均数

图 13.12:显示找到的单词和字母平均数

从图中,我们可以看到给定的随机单词是cambering,目前为止我已经找到了 4 个相关单词,因此页脚视图显示了这4个找到的单词。这些单词的平均字母数也一并显示,每个单词4个字母。

在我们继续添加更多反馈之前,让我们在addWordToList函数中再进行一次检查。我希望能够阻止用户输入单字母单词,这非常容易做到。任何给定语言中都有很多单字母单词,所以这并不是什么大问题,但我们还是应该尽量避免。在addWordToList函数中userWord声明之后,代码如下:

//guard against one letter words - they are too easy
        guard usersWord.count > 1 else {
            return
        }

我们正在检查usersWord的计数,看它是否大于 1;如果不是,我们不会允许列表中的单字母单词,函数将直接在这里返回。试一试,你就会发现你不能输入单字母单词。

接下来,为了给用户提供反馈,我们将继续添加触觉和音频到游戏中。

添加触觉和按钮声音

振动是一种通过访问 iPhone 内部振动硬件来实现的感觉反馈形式,当手机在使用时提供物理反应。你可能对振动很熟悉,因为每次我们设置手机振动时都能感觉到。我们不会使用完整的振动,而只是用户每次点击按钮时都能感觉到的短暂振动。

添加此类代码的最佳位置是在按钮本身,让我们这样做。我们将使用UIImpactFeedbackGenerator类来实现这个功能,实际上它相当简单。首先,在ContentView中,我们需要在该类下添加一个实例,位于我们已添加的所有属性下方:

//haptic feedback
var hapticImpact = UIImpactFeedbackGenerator(style: 
  .medium)

我们已经将hapticImpact变量样式设置为medium,但你也可以将其设置为heavylightridgedsoft

现在,为了使用这个功能,我们只需在按钮内部调用这个变量,如下所示:

            //MARK: - BUTTON
            Button(action: {
                hapticImpact.impactOccurred()
                appData.getRandomWord()

                   •••••••          

就这样——每次按下新单词按钮时,用户的手指都会感到轻微的振动。为了尝试这个功能,你实际上需要在设备上运行它,因为在模拟器中无法使用振动。

由于我们在ContentView按钮中实现了触觉反馈,那么我们是否也可以将其添加到SettingsView的语言按钮中呢?让我们进入SettingsView并添加与这里相同的代码。首先,在SettingsView文件顶部创建一个UIImpactFeedbackGenerator类的实例:

    //haptic feedback
       var hapticImpact = UIImpactFeedbackGenerator(style: 
         .light)

现在,在每个三个语言按钮中添加以下代码:

hapticImpact.impactOccurred()

就这样——为用户实现触觉反馈非常简单。

那么再添加一种我们已熟悉的反馈形式——音频如何?我们可以在新单词按钮上添加点击声音,这样用户不仅会感觉到按钮被按下,还能听到声音。

我们已经做过好几次了,所以为了简洁起见,我就不详细讲解代码了;相反,你可以简单地参考我们之前添加音频的许多其他项目(例如我们的唱片机项目)。

正如我们一贯的做法,我们创建一个新的PlaySound并在文件中添加以下代码:

import Foundation
import AVFoundation
var player: AVAudioPlayer?
func playSound(sound: String, type: String) {
    if let path = Bundle.main.path(forResource: sound, 
      ofType: type) {
        do {
        player = try AVAudioPlayer(contentsOf: 
          URL(fileURLWithPath: path))
        player?.play()

        } catch {
            print("Could not load audio file")
        }
    }
}

现在我们有了声音文件,我们只需要在 单词按钮的主体中使用它:

playSound(sound: "buttonClick", type: "m4a")

这样就完成了声音设置。

现在,当用户按下新单词按钮时,他们会有三种形式的反馈——他们看到按钮被按下,因为在按下过程中,按钮改变了其形状;他们用手指感觉到冲击,因为我们访问了手机的内部振动马达;他们听到点击声。这些反馈共同为用户在与应用程序交互时提供了更加丰富和触觉的体验。

摘要

在这个项目中,我们从开始到结束构建了一个完整的应用程序,并在过程中包含了不同的动画。

我们在数据模型中组织了属性和函数,创建了单独的头部和尾部视图,实现了选择控件,并创建了一些可以处理和检查单词在三种不同语言中真实性的特定函数。我们还以三种不同的方式实现了用户反馈——以弹出警告的形式、以触觉反馈的形式,以及作为音频。此外,我们还为游戏添加了三种语言,使其对学习语言的用户具有教育意义。

那么通过添加一些动画来进一步吸引玩家,通过回顾以前的项目并使用你已经在游戏的各个不同区域实现过的代码,怎么样?例如,如果用户在其列表中获得一定数量的单词,他们可能会获得一些在顶部动画标签中显示的积分作为奖励。或者,我们可以添加另一个按钮,要求用户只查找回文单词。

在下一章中,我们将构建另一个游戏——这次是一个颜色匹配游戏。

第十四章:创建一个颜色游戏

在上一章中,我们构建了一个游戏,用户必须使用更大单词的字母来找到单词。在本项目中,我们将继续“寻找”主题,并构建另一个游戏,其中用户必须在更广泛的颜色中找到颜色。

本项目的目标将是生成一个随机的 RGB 颜色,然后让用户操作每个单独 RGB 值的独立滑块,以完全匹配那个随机颜色。例如,将有一个用于红色的滑块,一个用于绿色的滑块,一个用于蓝色的滑块,每个滑块包含从 0 到 255 的范围(每个 RGB 值的范围)。然后,用户将必须调整这些滑块,看看他们是否能找到确切的 RGB 随机颜色。

我们还将为游戏添加三个难度级别,从简单到极端。如果用户足够熟练,能够计算出给定颜色中的单个 RGB 值,我们将在用户界面上方显示一场纸屑雨。

在构建这个游戏的过程中,您将学习如何使用 Swift 包将预构建的动画添加到项目中,包括当用户获得高分时的上述纸屑动画。我们还将为 SwiftUI 滑块添加弹簧动画,并在需要时使其可见,不需要时使其不可见。

因此,在本章中,我们将涵盖以下主题:

  • 理解颜色

  • 创建Title视图

  • 创建目标和猜测圆圈

  • 创建Picker视图

  • 创建目标和猜测矩形

  • 创建颜色滑块

  • 使用按钮跟踪用户的得分

  • Alert视图中显示用户的得分

  • 重置游戏

  • 添加背景

  • 使用 Swift 包添加纸屑

技术要求

和往常一样,您可以从 GitHub 上的“第十四章”文件夹中找到代码并下载完成的项目:github.com/PacktPublishing/Animating-SwiftUI-Applications.

理解颜色

在我们开始之前,让我们先快速了解一下颜色。您可能熟悉两种颜色模型,即原色和 RGB 颜色。

原色是从中可以派生出所有其他颜色的基本颜色。在传统色彩理论中,原色是红色、蓝色和黄色。原色在许多不同的领域中使用,例如艺术、印刷和图形设计。

RGB(代表红色、绿色和蓝色)颜色构成一个颜色模型,其中颜色被组合以创建不同强度的红、绿和蓝色光,这些光可以在电子显示屏和设备上显示,例如计算机、电视、手机和平板电脑。

RGB 颜色由它们的红色、绿色和蓝色分量表示,每个分量都有一个介于 0 到 255 之间的值。值 0 表示该颜色的缺失,而 255 表示该颜色的最大强度。通过改变 RGB 分量的值,可以创建广泛的颜色。例如,(255, 0, 0)表示纯红色,(0, 255, 0)表示纯绿色,(0, 0, 255)表示纯蓝色。这些颜色的组合可以创建许多不同的颜色和色调。

我们将添加三个滑块,它们将代表每个 RGB 颜色,用户必须操作这些滑块,通过组合不同数量的这些 RGB 颜色来找到目标颜色。

让我们直接开始并让这个游戏起飞。我们将首先向用户界面添加一个标题。

创建标题视图

让我们从创建一个新的 Xcode 项目开始——我将其命名为VStack以垂直组织视图。

此处的第一个目标将是给 UI 添加一个标题;代码不多,但我们仍然将其放在自己的文件中。因此,创建一个新的 SwiftUI 视图文件,并将其命名为TitleView,然后在body属性中添加以下代码:

struct TitleView: View {
    var body: some View {
        HStack {
            Text("Find").foregroundColor(.red)
            Text("The").foregroundColor(.green)
            Text("Color").foregroundColor(.blue)
        } .foregroundColor(.blue)
            .fontWeight(.black)
            .font(Font.system(size: 35, design: .serif))
    }
}

HStack内部有三个Text视图,每个视图都有不同的颜色——"Find"用红色, "The"用绿色,"Color"用蓝色——所有这些颜色都是使用foregroundColor修饰符着色的。

HStack之后,对视图应用了三个额外的修饰符。第一个是.foregroundColor(.blue),它将所有Text视图的文本颜色设置为蓝色。接下来是.fontWeight(.black),它将所有Text视图的字体重量设置为粗体。最后,.font(Font.system(size: 35, design: .serif))将所有Text视图的字体和大小设置为 35 点,采用 Serif 设计字体。

接下来,我们将回到ContentView并添加以下代码来显示标题:

struct ContentView: View {
    var body: some View {
        VStack {
                //MARK: - TITLE
                 TitleView().padding()
        }
    }
}

我还添加了一些padding,这样在添加完所有视图后,Title视图就不会太靠近 iPhone 顶部的缺口。

现在,我们可以看到启动 UI 的彩色标题:

图 14.1:游戏标题

图 14.1:游戏标题

这样,我们就可以继续到 UI 的下一部分,即目标和猜测圆圈。

创建目标和猜测圆圈

下一个任务是创建两个彩色圆圈:

  • 其中一个圆圈将是目标圆圈,它将显示一个随机生成的颜色;因为它是一个 RGB 颜色,用户需要组合三个 RGB 值来找到这个目标颜色。

  • 另一个圆圈将是他们的“猜测”圆圈——有点像草图板,用户可以在操作滑块时看到他们的当前进度。

这两个圆圈将创建在它们自己的 SwiftUI 文件中,所以现在让我们创建它们。按Command + N,选择TargetAndGuessCircleView

我们想在ContentView中使用这个文件——为了做到这一点,我们将在结构体内部需要一些Binding变量:

struct TargetAndGuessCircleView: View {
    //target variables
    @Binding var redTarget: Double
    @Binding var greenTarget: Double
    @Binding var blueTarget: Double

    //guess variables
    @Binding var redGuess: Double
    @Binding var greenGuess: Double
    @Binding var blueGuess: Double

    //picker variable
    @Binding var selectedPickerIndex: Int
           •••••••

该文件需要七个变量 – 三个用于目标圆,三个用于猜测圆,还有一个整数(我们将用于 Picker 视图)将允许用户选择难度级别。

添加了那些 Binding 属性后,我们注意到 previews 结构体中有一个错误。记住,当我们向文件添加这些变量时,previews 结构体必须使用一些占位符数据更新,以便它再次工作。因此,修改 previews 结构体,使其看起来像这样:

struct TargetAndGuessCircleView_Previews: PreviewProvider {
    static var previews: some View {
        TargetAndGuessCircleView(redTarget: .constant(0.3), 
        greenTarget: .constant(0.2), blueTarget: 
        .constant(0.7), redGuess: .constant(0.7), 
        greenGuess: .constant(0.4), blueGuess: 
        .constant(0.7), selectedPickerIndex: .constant(2))
    }
}

然后,让我们回到 body 属性。我们想要添加一个 ZStack,以便我们可以将目标圆叠加在猜测圆上。我们可以使用以下代码创建这两个圆:

ZStack {
    //MARK: - GUESS CIRCLE
    if selectedPickerIndex == 0 {
        Circle()
            .fill(Color(red: redGuess, green: greenTarget,
              blue: blueTarget, opacity: 1.0))
            .frame(height: 200)
    }
    else if selectedPickerIndex == 1 {
        Circle()
            .fill(Color(red: redGuess, green: greenGuess,
              blue: blueTarget, opacity: 1.0))
    .frame(height: 200)
    }
    else if selectedPickerIndex == 2 {
        Circle()
            .fill(Color(red: redGuess, green: greenGuess,
              blue: blueGuess, opacity: 1.0))
            .frame(height: 200)
    }

     //MARK: - TARGET CIRCLE
     Circle()
        .fill(Color(red: redTarget, green: greenTarget,
          blue: blueTarget, opacity: 1.0))
        .frame(height: 80)
}

ZStack 中,我们首先处理猜测圆;这将是一个较大的圆,frame 高度设置为 200。在这里,我们使用了一个 if else 语句并检查 selectedPickerIndex 变量以查看用户选择了哪个难度级别。如果他们选择了 0,那么我们将提供绿色和蓝色的 RGB 目标颜色给用户,这样他们只需要计算出红色的 RGB 目标值。如果他们选择了 1,那么我们只向用户提供蓝色的 RGB 值,他们必须自己计算出红色和绿色的值。最后,如果用户选择了 2,那么我们不向用户提供任何 RGB 目标值,他们必须自己计算出单独的目标颜色值。

接下来,我们创建目标圆 – 这将是用户需要通过操作滑块来尝试匹配的随机生成颜色的圆。请注意,我们创建的这个圆很小,只有 80 点,而且因为我们处于 ZStack 中,这个圆将在猜测圆的上方。这种圆叠加的原因是,当用户试图确定颜色时,他们的猜测圆会改变颜色,他们可以更好地可视化他们离目标颜色有多接近。

最后,在 ZStack 的末尾,我们使用了 onAppear 修饰符。这个修饰符会在 ZStack 重新绘制时运行一个动作;如果 ZStack 重新绘制,这意味着用户选择了玩新游戏(使用我们稍后创建的按钮),滑块将回到 0

这样就完成了 TargetAndGuessCircleView 文件。让我们回到 ContentView 并添加我们需要显示 TargetAndGuessCircleViewState 变量:

//target variables
    @State var redTarget = Double.random(in: 0..<1)
    @State var greenTarget = Double.random(in: 0..<1)
    @State var blueTarget = Double.random(in: 0..<1)
    //guess variables
    @State var redGuess: Double
    @State var greenGuess: Double
    @State var blueGuess: Double
    //picker variable
    @State var selectedPickerIndex = 1

该文件中有七个 State 属性,每个属性对应我们在上一个文件中创建的 Binding 属性之一。变量分别使用 Double.random(in: 0..<1) 方法设置为介于 01 之间的随机双精度值。函数每次被调用时都会生成一个新的随机数,这意味着每次用户开始新游戏时,都会使用一个新的随机值来选择 RGB 值。

从这里继续,我们注意到ContentViewPreviews结构体中有一个错误。这是一个熟悉的错误 - 这是 Xcode 告诉我们我们在ContentView结构体中声明了一个属性,但我们没有在Previews结构体中包含它。让我们通过在Previews结构体中输入一些数据来修复这个问题,如下所示:

ContentView(redGuess: 0.5, greenGuess: 0.5, blueGuess: 0.5)

这样就可以修复ContentView中的错误,但我们也需要更新包含项目主要启动代码的 Swift 文件。该文件将使用与您为应用程序命名的相同名称 - 我将此项目命名为Find the Color,因此在 Xcode 左侧的项目导航器中,将有一个扩展名为 Swift 的文件,称为Find_The_ColorApp.swift

点击那个 Swift 文件,并将代码更新为以下内容:

@main
struct Find_The_ColorApp: App {
  var body: some Scene {
      WindowGroup {
          ContentView(redGuess: 0.5, greenGuess: 0.5,
            blueGuess: 0.5)
      }
  }
}

我们现在可以回到ContentView,调用TargetAndGuessCircleView文件,并传入适当的State变量。在标题代码下方直接添加以下代码:

//MARK: - TARGET AND GUESS CIRCLES
    TargetAndGuessCircleView(redTarget: $redTarget,
      greenTarget: $greenTarget, blueTarget: $blueTarget,
      redGuess: $redGuess, greenGuess: $greenGuess,
      blueGuess: $blueGuess, selectedPickerIndex:
      $selectedPickerIndex)

这就是 UI 现在的样子:

图 14.2:目标和猜测圆圈

图 14.2:目标和猜测圆圈

让我们继续添加Picker视图,以便用户可以选择游戏的难度。

创建选择器视图

要创建Picker视图,让我们为此目的创建一个新的 SwiftUI View 文件,命名为PickerView

在这个文件中,我们需要添加一个Binding属性,以便我们可以在ContentView中使用它,以及为选择器添加一个标题数组:

    @Binding var selectedPickerIndex: Int
    @State var levels = ["Easy 😌", "Hard 😓", "Extreme! 
      🥵"]

这里的代码有一个Binding变量来保存selectedPickerIndex的值;这样,我们可以跟踪用户选择了选择器上的哪个按钮。然后,我们有一个包含标题的State数组,我们将使用它来为单个选择器按钮添加适当的表情符号,以表示难度级别。

接下来,就像我们之前做的那样,使用一些虚拟数据更新Previews结构体,以满足 Xcode 的要求:

struct PickerView_Previews: PreviewProvider {
    static var previews: some View {
        PickerView(selectedPickerIndex: .constant(1))
    }
}

预览功能运行正常后,我们现在可以创建和设置Picker控件。进入body属性并添加以下代码:

 var body: some View {
  VStack {
      Picker("Numbers", selection: $selectedPickerIndex) {
              ForEach(0 ..< levels.count, id: \.self) { 
                index in
                  Text(levels[index])
              }
          }
          .pickerStyle(SegmentedPickerStyle())
          .background(Color.yellow)
          .cornerRadius(8)
          .padding(.horizontal)
          .shadow(color: Color.black, radius: 2, y: 4)
          .padding(.top)

      Text("Difficulty Level: " + 
        "\(levels[selectedPickerIndex])").bold()
          .padding(5)
          .animation(.easeInOut(duration: 0.2), value:
            selectedPickerIndex)
      }
  }
}

这就是我们在这里所做的事情。

代码添加了一个Picker组件和一个Text组件。VStack用于垂直排列PickerText组件。Picker用于显示选项列表,选项是levels数组中的元素;它接受两个参数:Numbersselection: $selectedPickerIndex,其中Numbers是选择器的标签,$selectedPickerIndex是一个状态变量,用于跟踪当前选定的选项。

ForEach循环用于遍历levels数组,为数组中的每个元素创建一个新的Text组件。每个Text组件被配置为显示来自levels数组的相应元素。

pickerStyle方法用于将选择器的外观更改为分段选择器样式。我们之前已经使用了backgroundcornerRadiuspaddingshadow修饰符,并将各种视觉样式应用到选择器上。

文本组件用于在选择器组件下方显示 UI 中的难度级别。它将字符串"难度级别: " + "(levels[selectedPickerIndex])"设置为粗体,然后对其应用一些填充和动画。动画被添加,以便难度级别字符串不会立即出现和消失,而是以0.2秒的持续时间平滑地进入和退出,以实现更平滑的 UI 过渡。

现在,我们有一个选择器组件,可以用来选择游戏的难度级别,以及一个文本字符串来显示该级别。

结果如下所示:

图 14.3:分段选择器

图 14.3:分段选择器

完成选择器控制后,回到ContentView并直接在目标和猜测圆圈代码下方调用PickerView

//MARK: - PICKER
    PickerView(selectedPickerIndex: $selectedPickerIndex)

这段代码将PickerView文件添加到目标和猜测圆圈下方的 UI 中。结果如下所示:

图 14.4:UI 中的选择器

图 14.4:UI 中的选择器

现在,如果你点击选择器,你会看到猜测圆圈的颜色会改变;它不会改变到超过三种色调的颜色,因为选择器在预览运行时显示每个 RGB 值的一个随机颜色。如果你停止预览并重新启动它,将显示另一组三种随机颜色。

简而言之,我们将实现滑块来改变猜测圆圈的颜色,并在用户开始新游戏时改变目标圆圈的颜色。在那之前,让我们继续下一部分的 UI,制作目标和猜测矩形。我将在下面解释它们的作用。

创建目标和猜测矩形

到目前为止,我们给用户提供了一个猜测圆圈,这样他们可以看到他们离目标颜色有多近。让我们做类似的事情,给他们另一个视觉提示——让我们创建一个猜测矩形和一个目标矩形,并将它们直接放在选择器控制之下。我们还可以添加一个 RGB 值指示器,这样他们可以看到每个滑块的实际值,当他们在尝试确定需要移动滑块的多少以及哪个方向时,这可能很有帮助。

首先,创建一个新文件来制作这些矩形,命名为TargetAndGuessRectView。这个文件将包含与TargetAndGuessCircleView文件非常相似的代码,这意味着添加所需的绑定属性:

    @Binding var redTarget: Double
    @Binding var greenTarget: Double
    @Binding var blueTarget: Double
    @Binding var redGuess: Double
    @Binding var greenGuess: Double
    @Binding var blueGuess: Double
    @Binding var selectedPickerIndex: Int

你应该熟悉这些绑定变量,因为我们之前在TargetAndGuessCircleView文件中创建过它们。实际上,如果你想的话,可以重构所有代码并将两个猜测和目标文件合并到一个文件中,或者创建一个数据模型并使属性对所有文件可用...我将把这个留给你作为一个挑战。

接下来,我们需要结合使用VStacksHStacks来定位矩形文本视图。将此代码添加到文件的主体中(我将在下面解释它):

var body: some View {
  VStack {
      HStack {
          //MARK: - TARGET RECTANGLE
          VStack {
              Rectangle()
                  .foregroundColor(Color(red: redTarget,
                    green: greenTarget, blue: blueTarget, 
                    opacity: 1.0))
                  .cornerRadius(5)
                  .padding(.init(top: 0, leading: 10, 
                    bottom: 0, trailing: 0))
                  .frame(height: 40)
              Text("Target Color to Match").bold()
          }
          //MARK: - GUESS RECTANGLE
          VStack {
              if selectedPickerIndex == 0 {
                  Rectangle()
                      .foregroundColor(Color(red: redGuess,
                        green: greenTarget, blue: 
                        blueTarget, opacity: 1.0))
                      .modifier(rectModifier())
              }
             else if selectedPickerIndex == 1 {
                  Rectangle()
                .foregroundColor(Color(red: redGuess,
                  green: greenGuess, blue: blueTarget,
                  opacity: 1.0))
                .modifier(rectModifier())
         }
              else if selectedPickerIndex == 2 {
                   Rectangle()
                      .foregroundColor(Color(red: redGuess,
                        green: greenGuess, blue: blueGuess,
                        opacity: 1.0))
                      .modifier(rectModifier())
              }
              HStack {
                  Image(systemName: "r.circle.fill")
                      .foregroundColor(.red)
                  Text("\(Int(redGuess * 255.0))")
                      .font(.callout)
                  Image(systemName: "g.circle.fill")
                      .foregroundColor(.green)
                  Text("\(Int(greenGuess * 255.0))")
                      .font(.callout)
                  Image(systemName: "b.circle.fill")
                      .foregroundColor(.blue)
                  Text("\(Int(blueGuess * 255.0))")
                      .font(.callout)
              }
           }
      }
        }
    }
}
struct rectModifier : ViewModifier {
    func body(content: Content) -> some View {
        content
            .cornerRadius(5)
            .padding(.init(top: 0, leading: 0, bottom: 0,
              trailing: 10))
            .frame(height: 40)
    }
}

下面是对代码所做事情的分解。

第一个 VStack 包含一个矩形,其颜色由 redTargetgreenTargetblueTarget 的值决定。它还拥有 5 的圆角半径和前导边缘的填充,高度为 40。在矩形下方,有一个 Text 视图,以粗体字体显示 "Target Color to Match" 字符串。

第二个 VStack 包含一个矩形,其颜色由 redGuessgreenGuessblueGuess 的值决定,并且通过 rectModifier 结构体(稍后将对 rectModifier 结构体进行详细介绍)进行修改。矩形的颜色由 selectedPickerIndex 决定,它是一个整数值。如果 selectedPickerIndex0,颜色由 redGuessgreenTargetblueTarget 决定。如果 selectedPickerIndex1,颜色由 redGuessgreenGuessblueTarget 决定。如果 selectedPickerIndex2,颜色由 redGuessgreenGuessblueGuess 决定。在矩形下方,有一个包含图像和文本的 HStack,分别显示 redGuessgreenGuessblueGuess 的值。

现在目标和猜测矩形已经就位,以及三个将显示 RGB 值的 Text 视图,随着用户操作滑块,让我们继续查看最后一部分代码,即 rectModifer 结构体。

rectModifier 结构体是一个 ViewModifier,它修改 Rectangle 视图。它是一个符合 ViewModifier 协议的结构体,并定义了一个单一的方法,body(content: Content) -> some View,该方法接受一个 Content 类型的单个参数 content,并返回一个包裹在 view 中的修改后的内容版本。

在这个特定情况下,结构体正在修改 rectangle 视图的外观。通过在 body 方法中对 content 参数进行一系列视图修饰符的链式操作来完成修改,例如将圆角半径设置为 5,添加尾随边缘的填充,并将矩形的宽度设置为 40

当调用 rectModifier() 时,它将 rectangle 视图作为 content 参数传递,并将结构体中定义的修改应用于该 rectangle 视图,返回修改后的视图版本。换句话说,rectModifier() 是一种简单的方式来封装可以应用于任何视图的一组修饰符,并且它提供了一种在多个视图中重用同一组修饰符的方法,从而节省了编码时间和可读性。

以下是我们所编写的代码的结果:

图 14.5:目标和猜测矩形

图 14.5:目标和猜测矩形

滑块现在将显示用户的 RGB 值。

现在,是时候回到 ContentView 中,在 Picker 代码直接下方调用此文件了,这样我们就可以在主 UI 中显示这两个矩形和文本:

 //MARK: - TARGET AND GUESS RECTANGLES
    TargetAndGuessRectView(redTarget: $redTarget,
      greenTarget: $greenTarget, blueTarget: $blueTarget,
      redGuess: $redGuess, greenGuess: $greenGuess,
      blueGuess: $blueGuess, selectedPickerIndex:
      $selectedPickerIndex)

同样,这是熟悉的代码——我们只是调用 TargetAndGuessRectView 并传递适当的 State 变量。以下是结果:

图 14.6:UI 中的目标和猜测矩形

图 14.6:UI 中的目标和猜测矩形

我们只需要滑块和一个检查得分的按钮,所以现在就做吧。

制作颜色滑块

要开始创建滑块,创建一个新的 SwiftUI 视图文件,并将其命名为SliderView

由于滑块仅用于操作猜测圆圈,我们在这个文件中不需要任何Target变量。相反,我们只需要三个Guess变量,以及一个用于跟踪选择器控件值的变量,你可以这样添加:

    @Binding var redGuess: Double
    @Binding var greenGuess: Double
    @Binding var blueGuess: Double
    @Binding var selectedPickerIndex: Int

再次,我们需要更新Previews结构体,因为它在SliderView结构体的初始化器中给出了这个错误:Binding变量。让我们给它想要的,通过以下代码更新Previews

struct StyleTheSliders_Previews: PreviewProvider {
    static var previews: some View {
        SliderView(redGuess: .constant(0.5), greenGuess: 
          .constant(0.5), blueGuess: .constant(0.5), 
          selectedPickerIndex: .constant(1))
    }
}

预览功能恢复正常后,让我们继续创建一个用于创建滑块的单独的结构体。在SliderView结构体外部和下方添加CreateSlider

struct CreateSlider: View {
    @Binding var value: Double
    var color: Color
    var body: some View {
        HStack {
            Text("0")
                .bold()
                .foregroundColor(color)

            Slider(value: $value, in: 0.0...1.0)
            Text("255")
                .bold()
                .foregroundColor(color)
        }.padding(.init(top: 10, leading: 10, bottom: 10, 
          trailing: 10))
    }
}

这段代码定义了一个新的 SwiftUI 结构体CreateSlider,它接受两个参数:一个名为valueDouble类型的Binding变量,以及一个名为colorColor类型的变量。

下面是每行代码的作用。

如我们之前所见,@Binding var value: Double是一个属性包装器,它创建了一个结构体value变量和另一个变量之间的双向绑定。这允许结构体读取和写入它所绑定的变量的值。

var color: Color创建了一个名为color的变量,其类型为Color

Text("0")创建了一个值为0Text视图,并对其应用了以下修改器:.bold()使文本加粗,foregroundColor(color)将文本的颜色设置为传递给结构体的color变量。

然后我们添加滑块,Slider(value: $value, in: 0.0...1.0)。这创建了一个Slider视图,它读取并写入其值到value属性。in参数设置了滑块的取值范围为0.01.0

然后,我们再添加另一个Text视图,添加文本255,并使用.padding(.init(top: 10, leading: 10, bottom: 10, trailing: 10))给它添加一些填充。这是一个View修改器,它给视图的顶部、前导、底部和尾部边缘添加了10点的填充。

滑块创建后,我们将在SliderView结构体内部多次调用CreateSlider结构体,并对其添加一些样式。所以,回到SliderView结构体,添加以下代码:

var body: some View {
    //MARK: - SLIDERS FOR THE GUESS CIRCLE
    VStack {
        //red slider - this slider will always be visible
          and represents the "Easy" option on the picker
        CreateSlider(value: $redGuess, color: .red)
            .background(Capsule().stroke(Color.red,
              lineWidth: 3))
            .padding(.horizontal)
            .accentColor(.red)
            .padding(5)

        switch selectedPickerIndex {
        case 1:
            //green slider - shown when the "Hard" option 
              is selected
            CreateSlider(value: $greenGuess, color: .green)
                .background(Capsule().stroke(Color.green,
                  lineWidth: 4))
                .padding(.horizontal)
                .accentColor(.green)
                .padding(5)
        case 2:
          //blue slider - this is shown when the 
            "Extreme" option is selected
              CreateSlider(value: $greenGuess, color:
                .green)
                  .background(Capsule().stroke(Color.green,
                    lineWidth: 4))
                  .padding(.horizontal)
                  .accentColor(.green)
                  .padding(5)

              CreateSlider(value: $blueGuess, color: .blue)
                  .background(Capsule().stroke(Color.blue,
                    lineWidth: 4))
                  .padding(.horizontal)
                  .accentColor(.blue)
                  .padding(5)
          default:
              EmptyView()
          }
      }
  }

让我们分解这段代码。它首先定义了一个名为SliderView的 SwiftUI 视图,该视图使用VStack来布局三个滑块,用于调整redGuessgreenGuessblueGuess属性的值。这个结构体使用VStack来垂直组织三个滑块。

这三个滑块是通过调用CreateSlider视图添加的。CreateSlider视图使用background修改器和胶囊形状来用红色勾勒出滑块。

接下来是一个switch语句——由于我们第一次使用它,让我解释一下它是什么,以及使用switch语句和使用if else语句之间的区别。

因此,if else语句用于评估布尔表达式,如果表达式为true,则执行一个代码块,如果表达式为false,则执行另一个代码块。它可以用于任何类型的条件或比较。另一方面,switch语句用于将值或表达式与多个可能的案例进行匹配,并为匹配的案例执行一个代码块。

switch语句通常用于匹配单个变量或表达式的多个可能值,并且常用于需要以更简洁和可读的方式检查多个条件的情况。例如,当你有多个条件,并且使用多个if else语句时,你可以使用switch语句而不是if else。这使得代码更易于阅读和维护。

注意

通常,if else语句适用于复杂条件,而switch语句适用于简单条件。在 Swift 中,你可以根据要达成的目标选择使用其中之一。

因此,回到我们的代码,switch语句检查selectedPickerIndex的值,看它是否等于1;如果是,这意味着用户选择了selectedPickerIndex的值等于2,它将创建绿色和蓝色滑块,因为用户选择的selectedPickerIndex不是12,它将返回一个空视图。

接下来,使用CreateSlider结构体创建滑块,它接受两个参数,valuecolor。它显示滑块的最小和最大值,并使用color参数改变"0"文本视图和"255"文本视图的颜色。

SliderView使用CreateSlider结构体创建 RGB 颜色的滑块。selectedPickerIndex绑定用于根据用户的选取决定显示哪些滑块。

SliderView现在已经完成,所以让我们在ContentView中使用它。在Picker代码之后添加以下代码:

 //MARK: - SLIDER
    Spacer()
    SliderView(redGuess: $redGuess, greenGuess: 
      $greenGuess, blueGuess: $blueGuess, 
      selectedPickerIndex: $selectedPickerIndex)
        .scaleEffect(1)
        .animation(.interactiveSpring(response: 0.4, 
          dampingFraction: 0.5, blendDuration: 0.5), value: 
          selectedPickerIndex)
     Spacer()

这段代码调用SliderView结构体并填充Binding变量,然后在 UI 中滑块出现和消失时添加弹簧动画。interactiveSpring动画是一种模拟弹簧行为的动画,它接受三个参数:

  • response:此值控制弹簧的刚度。较低的值将导致弹簧较软,而较高的值将导致弹簧较硬。0.4的值将提供适中的刚度。

  • dampingFraction:这个值控制弹簧的阻尼,这是衡量弹簧振荡衰减速度的一个指标。较低的值将导致阻尼较小的弹簧,而较高的值将导致阻尼较大的弹簧。0.5的值意味着弹簧的振荡会相对较快地衰减。

  • blendDuration:这个值控制动画混合的持续时间。较低的值将导致较短的混合持续时间,而较高的值将导致较长的混合持续时间。0.5秒的值是一个适中的混合持续时间。

除了interactiveSpring动画之外,我们还使用了scaleEffect修饰符,并传入了一个值为1的值。这样做有助于平滑动画,因为每个滑块出现和消失时。试试看。如果你点击选择器控件来选择难度级别,每个滑块都会在屏幕上以弹簧动画的方式出现和消失:

图 14.7:滑块

图 14.7:滑块

当你移动滑块时,猜测圆的颜色将平滑更新,RGB 值将在 RGB 文本字符串中改变。

需要添加的最后 UI 组件是一个按钮,允许用户在游戏过程中检查他们的分数。

使用按钮跟踪用户的分数

要使这个成为游戏,用户需要一种方式来检查他们的进度并看到一个数字,反映他们离完美分数/颜色匹配有多近。

让我们在ContentView中的SliderView结构的Spacer()之后添加一个按钮:

 //MARK: - BUTTON
    Button(action: {

    }) {
        Text("Check Score")
            .foregroundColor(.black)
            .padding(EdgeInsets(top: 12, leading: 20, 
              bottom: 12, trailing: 20))
            .background(Color.yellow)
            .cornerRadius(20)
            .shadow(color: Color.black, radius: 2, y: 4)
    }

这段代码创建了一个名为Check ScoreButton视图。按钮使用黑色前景、黄色背景、填充、圆角半径,最后是一个黑色阴影,使按钮略显突出。

按钮的主体目前是空的,但我们的目的是在那里放入一些代码,这将触发一个警报并显示用户的当前分数,这样他们就知道自己离目标值有多近。

要做到这一点,我们需要创建一个计算分数的函数。因此,在ContentView结构的底部,添加以下代码:

func calculateScore() -> Int {
        let redDiff = redGuess - redTarget
        let greenDiff = greenGuess - greenTarget
        let blueDiff = blueGuess - blueTarget
        let easyDifference = redDiff * redDiff
        let hardDifference = easyDifference + greenDiff * 
          greenDiff
        let extremeDifference = hardDifference + blueDiff * 
          blueDiff
        let calculatedDifference: Double
        switch selectedPickerIndex {
            case 0:
                calculatedDifference = sqrt(easyDifference)
            case 1:
                calculatedDifference = sqrt(hardDifference)
            case 2:
                calculatedDifference = 
                  sqrt(extremeDifference)
            default:
                calculatedDifference = 0.0
        }
        return Int((1.0 - calculatedDifference) * 100 + 
          0.5)
    } 

这段代码定义了一个名为calculateScore的函数,它返回一个Int值。这个函数用于根据redgreenblueguess值与redgreenbluetarget值之间的差异来计算分数。

这就是它的工作原理。函数首先计算guess值和target值之间的差异,针对redgreenblue。接下来,它通过获取redDiff变量的平方根来计算easyDifference。然后,它通过将greenDiff变量的平方加到easyDifference上来计算hardDifference。最后,它通过将blueDiff变量的平方根加到hardDifference上来计算extremeDifference

之后,它使用switch语句来确定当前选中的难度级别,并根据该值分配calculatedDifference变量的值:

  • 如果selectedPickerIndex0,则calculatedDifferenceeasyDifference的平方根

  • 如果selectedPickerIndex1,则calculatedDifferencehardDifference的平方根

  • 如果selectedPickerIndex2,则calculatedDifferenceextremeDifference的平方根

  • 如果selectedPickerIndex是任何其他值,则calculatedDifference设置为0.0

最后,它返回一个通过计算(1.0 - calculatedDifference) * 100 + 0.5得到的integer值,这是最终分数。

此函数用于根据guess值与target值的接近程度计算一个分数。值之间的差异越小,分数越高。如果guess值等于target值,用户将获得完美的 100 分。

现在,calculateScore函数已完成;让我们使用它来在Alert视图中显示用户的得分。

在警告视图中显示用户的得分

要创建一个Alert视图,我们首先需要一个状态变量来跟踪警告并在state值变化时触发它。在文件顶部,在其他变量下方添加以下State变量:

//user feedback variable
    @State var showAlert = false

然后,我们需要在Button代码的闭合括号后添加Alert修饰符:

.alert(isPresented: $showAlert) { () -> Alert in
        Alert(title: Text("Your Score"), message:           Text("\(calculateScore())"),
              primaryButton: Alert.Button.default(Text("New
                Game?"), action: {
            // Start a new game?
        }),
              secondaryButton: 
                Alert.Button.destructive(Text("Continue 
                  Playing"), action: {
            // Continue with the present game

        }))
    }

代码创建了一个标题、一条消息和两个按钮——一个主要按钮和一个次要按钮。

标题将是用户的消息,消息将是用户的得分。我们通过在Text视图中调用calculatedScore函数并使用字符串插值来实现这一点;记住,calculateScore函数返回一个整数,该值将显示为用户的当前得分。

然后,主要按钮将提示用户开始新游戏,而次要按钮将提示用户继续玩游戏。

要触发警告,我们需要在Button的主体内部将showAlert变量切换到true,如下所示:

    Button(action: {
                showAlert = true
            }) {

现在,我们可以尝试玩游戏。滑动滑块,尽量接近目标圆圈的颜色,然后按下检查得分按钮查看您的得分:

图 14.8:显示用户得分的警告视图

图 14.8:显示用户得分的警告视图

从这个屏幕,如果用户按下继续玩游戏?,游戏将从上次离开的地方继续。然而,如果他们按下新游戏?,则不会发生任何事情——那是因为我们还没有添加任何逻辑来开始新游戏。让我们接下来做这件事。

重置游戏

我们希望给用户一个开始新游戏的机会,我们可以通过向文件中添加一个reset函数来实现这一点。此函数将滑块重置为0并为中间的目标圆圈生成另一个随机目标颜色。

calculateScore函数下方添加以下函数:

 //MARK: - RESET THE GAME
    func reset() {
        redTarget = Double.random(in: 0..<1)
        greenTarget = Double.random(in: 0..<1)
        blueTarget = Double.random(in: 0..<1)
        redGuess = 0.0
        greenGuess = 0.0
        blueGuess = 0.0
    }

此函数创建新的随机颜色并将它们存储在 Target 变量中。它还将 Guess 变量重置回 0,这将使所有滑块回到左侧的起始点。

让我们在 Alert 修饰符的次要按钮中调用 reset 函数,如下所示:

.alert(isPresented: $showAlert) { () -> Alert in
  Alert(title: Text("Your Score"), message:
    Text("\(calculateScore())"),
        primaryButton: Alert.Button.default(Text("Continue
          Playing?"), action: {
                }),
        secondaryButton: Alert.Button.destructive(Text("New 
          Game?"), action: {
      // Start a new game?
      reset()
  }))

现在,当我们按下次要按钮时,新游戏?,一个新的游戏开始,带有新的目标颜色,滑块被设置回起始位置:

图 14.9:开始新游戏

图 14.9:开始新游戏

滑块被设置回零,并生成并显示一个新的随机目标颜色。

让我们通过添加两个更多的事物来完成这个项目——一个背景,以及当用户得到 100 分完美分数时出现的纸屑动画。

添加背景

首先,是背景。您可以在 GitHub 的 第十四章 文件夹中找到此项目的资源(文件夹中唯一的资产);只需将 background 图像文件拖放到资产目录中。完成此操作后,在 ContentView 中,在主 VStack 的闭合括号之后添加以下代码:

.background(Image("background").resizable().edgesIgnoringSa
  feArea(.all))

在这里,我们使用 background 修饰符,传递图像名称,调整大小,并将 edgesIgnoringSafeArea 设置为拉伸整个屏幕。这是一个微妙的背景,但它有一个漂亮的图案,我认为它适合作为彩色 UI 的背景:

图 14.10:添加背景

图 14.10:背景已添加

现在,让我们看看 Swift 包,以及如何制作一些纸屑。

使用 Swift 包添加纸屑

Swift 包是预先构建的软件,我们可以在我们的任何项目中使用它,其中所有编码工作都为我们完成;我们只需在我们的项目中配置它。

要添加 Swift 包,在 Xcode 中,转到 文件 菜单并选择 添加包…

图 14.11:添加包…

图 14.11:添加包…

所有源 窗口中,在搜索框中输入 github.com/simibac/ConfettiSwiftUI.git

图 14.12:添加 URL

图 14.12:添加 URL

当点击添加包按钮时,Xcode 将 Confetti 包添加到项目中。Confetti 包包含创建所有种类纸屑所需的所有代码,以不同的方式分散。

现在,我们需要将 Confetti 框架导入到 ContentView 文件的最顶部:

import ConfettiSwiftUI

接下来,我们需要一个可以触发纸屑的变量(放置在其他项目变量之后):

//confetti variable
    @State var counter = 0

现在,我们可以在 ContentView 中调用 confettiCannon 初始化器,就在目标和猜测矩形代码之后,如下所示:

//MARK: - CONFETTI CANNON
    .confettiCannon(counter: $counter, num: 100, colors: 
      [.pink, .red, .blue, .purple, .orange], rainHeight: 
      1800.0, radius: 500.0)

此代码调用 confettiCannon 初始化器,使用 counter 变量来触发动画。创建的彩带数量将是 100,colors 数组允许您添加您想要彩带具有的颜色。rainHeight 将设置彩带上升的高度,而半径将设置彩带扩散的宽度。

注意

我已经为我们的示例填写了变量;然而,您可以玩弄这些值并按您喜欢的配置它们。创建者 Simon Bachmann 在 confettiCannon 初始化器中构建了许多参数,因此它非常可定制。要获取完整列表,请点击此链接:github.com/simibac/ConfettiSwiftUI#parameters

最后,我们希望在用户得到满分 100 分时,只在 Button 主体中触发彩带。因此,在将 showAlert 变量设置为 true 后,将以下代码添加到 Button 主体中,然后我们可以测试动画:

 Button(action: {
        showAlert = true
        //if a score of 100 is achieved, make the confetti 
          fall by adding 1 to the counter variable
        if calculateScore() == 100 {
               counter += 1
                     }

这只是一个简单的 if 语句,用于检查 calculateScore 函数是否返回 100。如果是这样,我们将 1 添加到 counter 变量中,这将触发动画。

如果您想知道如何通过添加 1 的值来触发彩带发射器,那么,这正是 Confetti 包的配置方式——当向其 counter 变量添加 1 的值时,它会触发彩带。

好的,现在当用户得到 100 分时,彩带发射器将从屏幕中央向上发射彩带,彩带将慢慢在整个界面上空飘落,直到屏幕底部:

图 14.13:彩带动画

图 14.13:彩带动画

这样,颜色匹配游戏就完成了。

摘要

在这个项目中,我们学习了关于 RGB 颜色、switch 语句、实现 Picker 视图、添加 Slider 视图、创建 reset 函数,以及使用 Swift 包将预构建代码导入到我们的项目中。

玩弄一下代码,想想您如何将游戏进一步发展。例如,也许您想在得到满分时播放声音效果,比如“砰”的一声,或者也许您想在 UI 中的标签上显示分数,而不是必须点击按钮来检查它。您可以为项目添加更多乐趣的许多不同的事情。

下一章将探讨一些通过将 SpriteKit 框架集成到我们的 SwiftUI 项目中可以创建的高级动画。SpriteKit 为我们提供了一个粒子系统,可以创建各种非常逼真的效果和动画,您可以在项目中使用。

第十五章:将 SpriteKit 集成到你的 SwiftUI 项目中

在本章中,我们将更深入地探索 SwiftUI 中的动画世界,并创建一些小型项目,突出展示可以使用其他技术和 SpriteKit 框架制作的动画。一些动画将比较简单,一些将更复杂,还有一些将是动态和交互式的动画。

你将学会如何利用SpriteKit粒子发射器的强大功能。SpriteKit 是一个游戏开发框架,它为 iOS 和 macOS 平台提供了创建 2D 游戏的便捷和高效方式。另一方面,粒子发射器是 SpriteKit 框架中的强大工具,它允许你创建特殊效果,如火焰、烟雾、雨、风、爆炸等。这些发射器将为你的应用带来活力,使其在视觉上令人惊叹,并吸引用户。

因此,以下是我们在本章最后将要涵盖的主题:

  • 动画管道烟雾

  • 动画咖啡蒸汽

  • 动画火箭火焰

  • 动画暴风雪

  • 动画雨

  • 动画魔法棒

技术要求

你可以在 GitHub 上的Chapter 15文件夹中找到完成的项目及其代码:github.com/PacktPublishing/Animating-SwiftUI-Applications

动画管道烟雾

在这个项目中,我们将使用 SpriteKit 的Smoke模板创建烟雾效果,并使其从管道图片中冒出。这是一个很好的入门项目,因为它并不复杂,让你熟悉创建所需的SpriteKit 场景SKS)粒子文件以及如何配置它。

让我们开始吧,首先创建一个新的 SwiftUI 项目,并将其命名为Pipe Smoke。为了这个项目,我们需要几幅图片,你可以在 GitHub 上(Chapter 15 | Pipe Smoke)找到它们,并将它们添加到项目的 Assets 目录中。现在,是时候创建一个 SpriteKit 粒子文件了。

创建 SpriteKit 粒子文件

接下来,让我们创建一个新的文件;这将是一个 SpriteKit 粒子文件。SpriteKit 场景SKS粒子文件是一个包含粒子系统信息的场景文件,例如粒子发射器的形状、大小和位置,以及将要发射的粒子的类型、行为和运动。

要创建一个 SKS 粒子文件,在 Xcode 中,你只需转到文件 | 新建 | 文件 | SpriteKit 粒子文件,或者按Command + N来显示模板选项,然后向下滚动直到你看到一个名为SpriteKit 粒子文件的模板。

现在,我们需要选择我们想要的模板类型。有几个选项,我们将分别在不同的项目中探索它们,但现在我们想要的模板是Smoke

图 15.1:粒子模板选项

图 15.1:粒子模板选项

一旦点击Smoke,选择一个保存位置,然后点击创建

图 15.2:创建 Smoke.sks 文件

图 15.2:创建 Smoke.sks 文件

现在,你将在你的 Xcode 编辑器中看到一个新粒子文件,其中正在播放烟雾动画。这是因为我们选择了烟雾模板,所以粒子已经预先配置为产生烟雾。这种烟雾需要调整,使其不那么宽厚。我们希望效果足够薄小,可以从管道中冒出。

要配置烟雾,转到 Xcode 的右侧,你会看到四个按钮;如果你点击第四个按钮,就会打开属性面板,这是我们配置粒子文件以创建无限多种效果的地方:

图 15.3:Xcode 的属性面板

图 15.3:Xcode 的属性面板

此面板中有相当多的设置,由于我们将在本章中创建不同的粒子文件,了解每个字段的作用是个好主意。因此,我将按顺序解释每个字段,以便你创建本章的第一个项目,并且你可以将这些定义作为你在继续处理其他项目时的参考。

名称

名称字段用于为粒子发射器提供一个唯一的标识符,这样你就可以在你的代码或编辑器中轻松地引用它。该字段是一个字符串值,你可以将其设置为任何你喜欢的名称,只要它在文件中是唯一的即可。通过为你的粒子发射器赋予描述性的名称,它们将作为它们所执行的操作以及在你项目中如何使用的提醒。

此外,你可以使用名称字段来访问粒子发射器,并在你的代码中以编程方式修改其属性。

背景

背景字段允许你设置编辑器的背景颜色。有时,调整此颜色以帮助使粒子更加突出是有用的。

纹理

纹理字段用于指定用作粒子纹理的图像文件。粒子纹理基本上是粒子的外观,你可以使用任何你喜欢的图像文件来创建各种粒子效果。例如,你可以使用简单的点或圆形图像来创建简单的粒子效果,或者使用具有多种形状和颜色的更复杂图像来创建更复杂的效果。当你指定粒子纹理在属性面板中时,它将用于粒子发射器中的所有粒子。

发射器

发射器是定义粒子特性的对象,例如其初始位置、速度和寿命。

发射器出生率字段指定每秒发射的粒子数量。它决定了发射器生成粒子的速度——较高的出生率会在较短时间内发射更多粒子,而较低的出生率会在较长时间内发射较少粒子。

发射最大值字段设置粒子系统中一次可以存在的最大粒子数。如果出生率设置为高值且最大值设置为低值,发射器将以指定的速率发射粒子,直到达到最大限制。一旦达到最大粒子数,发射器将停止发射新的粒子,直到一些现有的粒子消失。这允许您控制粒子系统中粒子的总数并优化性能。

生命周期

生命周期字段指定粒子系统中的每个粒子在消失之前将活跃的时间长度。

生命周期起始字段指定每个粒子在粒子系统中存在的时间长度。它决定了每个粒子在场景中保持可见的时间长度,然后消失。较高的值将导致粒子保持可见的时间更长,而较低的值将导致粒子更快消失。

生命周期范围字段设置生命周期属性可以取的值范围。您可以为生命周期指定一个固定值,而不是指定一个值范围,粒子系统将为每个发射的粒子从这个范围中随机选择一个值。这允许您为粒子系统添加变化,使其看起来更加自然和有机。例如,如果您将生命周期起始设置为1.0,将生命周期范围设置为0.5,则每个粒子的生命周期将是介于 0.5 秒和 1.5 秒之间的随机值。

位置范围

位置范围字段指定粒子在每个维度(XYZ)中初始位置值的范围,允许您创建从定义区域内随机位置发射粒子的粒子系统。粒子系统将为每个发射的粒子从指定范围内随机选择一个位置,这有助于为粒子系统添加多样性和现实感。

角度

在 SKS 粒子文件中的角度字段控制粒子在发射时的初始方向和扩散。角度起始设置粒子的起始方向,而角度范围确定可能的方向范围;系统随后为每个发射的粒子在这个范围内随机选择一个角度。角度起始以度为单位测量,0 度指向右侧,角度范围指定逆时针方向的角度扩散,同样以度为单位。

速度

速度字段控制当在 SKS 粒子文件中发射粒子时的初始速度和变化。速度起始设置起始速度,而速度范围指定可能的速度范围;然后为每个发射的粒子在指定范围内选择一个随机速度。

调整这些字段允许您创建各种粒子效果,值越高,粒子速度越快,值越低,粒子速度越慢。例如,将Speed Start设置为100,将Speed Range设置为0将发射以恒定速度运动的粒子,而将Speed Start设置为50,将Speed Range设置为25将创建速度在 25 到 75 之间的可变速度粒子。这些值通常被认为是每秒点数。

加速度

Acceleration XAcceleration Y字段分别控制粒子在xy方向上的加速度。例如,您可以将Acceleration X设置为0,将Acceleration Y设置为-100,以创建受重力影响的粒子系统,使粒子向下坠落。或者,您可以将Acceleration X设置为50,将Acceleration Y设置为0,以创建以恒定速度向右移动的粒子系统。

Acceleration XAcceleration Y字段以每秒平方点为单位指定;正值将使粒子向正方向加速,而负值将使粒子减速或向相反方向加速。

Alpha

.sks文件指的是粒子系统中粒子的不透明度或透明度。alpha 值控制粒子的可见程度,值越高,粒子越明显,值越低,粒子越透明。

Alpha Start字段指定粒子的起始 alpha 值,可用于控制其初始透明度。Alpha Range字段指定粒子可能采取的 alpha 值的范围,当粒子发射器发射粒子时,它将在指定范围内选择一个随机的 alpha 值。Alpha Speed字段指定 alpha 值随时间变化的速率,可用于控制粒子的淡入或淡出速率;此字段的正值将使粒子随时间逐渐淡入,而负值将使粒子随时间逐渐淡出。

通过调整Alpha StartAlpha RangeAlpha Speed字段,您可以控制粒子在其生命周期内的透明度,并创建各种粒子效果。例如,您可以将Alpha Start设置为1,将Alpha Range设置为0,以发射具有恒定 alpha 值的粒子,或者将Alpha Start设置为1Alpha Range设置为0,将Alpha Speed设置为-0.5,以发射随时间逐渐消失的粒子。

缩放

缩放字段控制粒子在其生命周期内的尺寸。缩放起始字段指定了粒子的起始尺寸,可以用来控制它们的初始大小。缩放范围字段指定了粒子可能采取的尺寸范围,当粒子发射器发射粒子时,它将在指定的范围内选择一个随机的大小。缩放速度字段指定了粒子尺寸随时间变化的速率,可以用来控制粒子生长或缩小的速率;此字段的正值将导致粒子随时间生长,而负值将导致粒子随时间缩小。

作为使用缩放的示例,你可以将缩放起始设置为1缩放范围设置为0以发射具有恒定大小的粒子,或者将缩放起始设置为1缩放范围设置为0.5缩放速度设置为0.1以发射随时间生长的粒子。

旋转

旋转起始字段指定了粒子的起始旋转,这可以用来控制它们的初始方向。旋转范围字段指定了粒子可能采取的旋转范围,当粒子发射器发射粒子时,它将在指定的范围内选择一个随机的旋转。旋转速度字段指定了粒子旋转随时间变化的速率,可以用来控制粒子旋转的速度;正值将使粒子顺时针旋转,而负值将使粒子逆时针旋转。

你可以将旋转起始设置为0旋转范围设置为0以发射具有恒定方向的粒子,或者将旋转起始设置为0旋转范围设置为180旋转速度设置为180以发射随时间快速旋转的粒子。

颜色混合

颜色混合字段控制粒子在其生命周期内的颜色。

颜色混合因子字段指定了将应用于粒子的颜色混合量。当粒子发射器发射粒子时,它将在指定的范围内选择一个随机的颜色混合因子。粒子的颜色将根据这个混合因子与粒子纹理的颜色混合。

颜色混合因子范围字段指定了粒子可能采取的颜色混合因子的范围。值为0将导致没有颜色混合,而值为1将导致完全颜色混合。

颜色混合因子速度字段指定了颜色混合因子随时间变化的速率,可以用来控制粒子颜色变化的速率。颜色混合因子速度字段的正值将导致粒子颜色随时间变化,而负值将导致粒子颜色反向变化。

以一个示例来看,您可以将颜色混合因子设置为0颜色混合因子范围设置为0,以及颜色混合因子速度设置为0来发射具有恒定颜色的粒子,或者将颜色混合因子设置为1颜色混合因子范围设置为1,以及颜色混合因子速度设置为0.1来发射随时间快速改变颜色的粒子。

颜色渐变

颜色渐变字段用于指定粒子在其生命周期内可以采取的颜色范围。颜色是通过渐变定义的,渐变是两种或多种颜色的渐变。

颜色渐变允许您创建具有变化颜色的粒子效果。默认情况下,粒子将以单色发射,但通过调整颜色渐变,您可以设置粒子为固定范围的多种颜色,随时间改变颜色,或随机化范围内的颜色。例如,您可以将颜色渐变设置为红色和黄色的阴影以创建火焰效果,或蓝色和白色的阴影以创建雪效果。

此外,颜色渐变还可以用于创建不同的混合模式,如加法混合或减法混合。混合模式决定了粒子的颜色如何与背景或场景中的其他粒子的颜色结合。

混合模式

混合是一个过程,它将屏幕上粒子的颜色与它们后面的物体的颜色结合起来。混合模式字段决定了粒子的颜色和背景颜色如何结合。

SpriteKit 中提供了几种混合模式,选择哪种取决于对粒子所需视觉效果的期望。例如,Alpha混合模式会考虑粒子的颜色通道的透明度来混合粒子和背景的颜色。混合模式会将粒子和背景的颜色相加。混合模式会将粒子和背景的颜色相乘。

通过调整混合模式,您可以控制粒子的颜色如何与它们后面的物体的颜色混合,并创建广泛的粒子效果。

场域掩码

场域掩码接受一个整数值,用于指定一个掩码位字段,位字段定义了粒子的哪些属性受掩码影响。例如,如果位字段包括位置位,掩码将影响粒子的位置。如果包括颜色位,掩码将影响粒子的颜色。通过设置场域掩码的值,您可以控制粒子的哪些属性受掩码影响。这允许您创建复杂和精细的粒子效果。

例如,你可以将字段掩码设置为包含位置位,这将导致掩码影响粒子的位置,并创建出遵循特定路径或形状的粒子。否则,你可以将字段掩码设置为包含颜色位,这将导致掩码影响粒子的颜色,并创建出具有特定颜色方案或模式的粒子。

自定义着色器

自定义着色器字段允许你指定用于渲染粒子的自定义着色器。

着色器是一个在 GPU 上运行的程序,用于定义粒子的外观和行为。通过使用自定义着色器,你可以创建出复杂而精致的粒子效果,这些效果使用粒子发射器的内置属性难以实现或根本无法实现。

例如,你可以使用自定义着色器来创建对环境变化(如光线或阴影)做出反应的粒子,或者创建随时间改变形状或外观的粒子。要使用它,你需要用类似于OpenGL 着色语言GLSL)的语言编写着色器代码,然后指定着色器代码作为自定义着色器字段的值。一旦指定了自定义着色器,SpriteKit 就会使用它来渲染粒子,从而让你完全控制粒子的外观和行为。通过将自定义着色器与其他粒子发射器的属性(如出生率生命周期位置范围)结合使用,你可以创建出广泛而视觉上令人惊叹的粒子效果。

现在,我们已经查看了粒子系统的所有自定义字段。我知道这可能会有些令人不知所措,这就是为什么我在文件中定义了每个字段,以便你在构建粒子系统时可以参考。

让我们使用以下值来改变烟雾,使其类似于从管道中看到的烟雾。我已经计算出所有值;你只需要填写它们:

图 15.4:管道烟雾动画属性

图 15.4:管道烟雾动画属性

在继续之前,请确保你的文件也有相同的值,这将为你提供从管道中出来的正确烟雾。

将烟雾文件集成到 SwiftUI 视图中

在我们开始本节之前,需要稍微解释一下 SpriteKit 框架是什么以及它是如何工作的。正如我在本章开头提到的,SpriteKit 是一个游戏开发框架,它为 iOS 和 macOS 平台提供了创建 2D 游戏的一种方便且高效的方式。使用 SpriteKit 框架,你可以为你的动画或游戏创建不同的精灵。

精灵由 SKSpriteNode 类表示,这是一个可以显示纹理图像的节点。精灵可以被视为动画的单帧,它可以移动、旋转和缩放,并且可以应用物理属性。单个精灵也可以有多个纹理,允许它改变外观。在游戏中,精灵用于表示角色、对象和背景。

你可以通过使用图像或纹理初始化 SKSpriteNode 并将其添加到 SKScene 中来创建一个精灵。一旦添加到场景中,精灵就可以使用各种属性和方法进行操作,例如位置、缩放和旋转,你还可以向其添加动作、物理属性和手势。

SKView 是一个 UIView 子类,用于显示和交互 SpriteKit 场景。它作为 SKScene 的容器,并为显示和动画精灵提供必要的基础设施。SKScene 是基于 SpriteKit 的项目中所有精灵的容器。它负责更新和渲染精灵,并提供处理用户与精灵交互的方式。场景还可以包含其他节点,如标签和形状,除了精灵之外。每个场景都可以有自己的精灵集、物理属性和交互。随着我们在这个章节的项目中不断前进,你将更加熟悉精灵和节点。

让我们现在进行一些编码。进入 ContentView,我们将创建一个烟雾视图结构体,在其中我们可以使用我们的 Smoke.sks 文件。我们首先需要做的是导入 SpriteKit 框架,以便我们可以访问类和方法。在 ContentView 的顶部添加以下代码行:

import SpriteKit

现在,让我们创建我提到的那个 SmokeView 结构体,通过在 ContentView 结构体下添加以下内容:

struct SmokeView: UIViewRepresentable {
    func makeUIView(context: 
      UIViewRepresentableContext<SmokeView>) -> SKView {
        let view = SKView(frame: CGRect(x: 0, y: 0, width: 
          400, height: 400))
        view.backgroundColor = .clear
        let scene = SKScene(size: CGSize(width: 400, 
          height: 600))
        ///set the scenes background color to clear because 
          we will set the color in the ContentView.
        ///You can also use any other valid color like 
          UIColor.lightGray, UIColor.green, 
          UIColor.init(red: 1, green: 1, blue: 1, alpha: 
          0.5) or any other UIColor initializer.
        scene.backgroundColor = UIColor.clear
        guard let smoke = SKEmitterNode(fileNamed: 
          "Smoke.sks") else { return SKView() }
        smoke.position = CGPoint(x: scene.size.width / 2, 
          y: scene.size.height / 2)
        // set the blend mode - scale - range
        smoke.particleBlendMode = .screen
        smoke.particleScale = 0.01
        smoke.particleScaleRange = 0.05
        ///add the smoke to the scene
        scene.addChild(smoke)
        view.presentScene(scene)
        return view
    }
    func updateUIView(_ uiView: SKView, context: 
      UIViewRepresentableContext<SmokeView>) {
      /// Update the smoke in this function if you need to
    }
}

下面是如何逐行工作 SmokeView 结构体。请注意,SpriteKit 框架使用的是我们之前未使用过的方法和类,所以代码一开始可能看起来不熟悉;然而,随着你在这个章节中继续工作,你会很快理解它,因为毕竟它是 Swift 代码,并且非常易于阅读:

  • 这段代码首先定义了一个名为 SmokeView 的结构体,该结构体用于显示我们在 Smoke.sks 文件中配置的烟雾效果。该结构体遵循 UIViewRepresentable 协议。这使得结构体可以用作 SwiftUI 视图。

  • 接下来,我们添加 makeUIView 方法,这是 UIViewRepresentable 协议所要求的。它创建并返回 SKView,用于显示烟雾效果。

  • 然后,我们创建一个具有指定框架(大小和位置)的 SKView 实例。这个框架用于确定烟雾效果的大小。

  • 我们将 SKView 实例的 backgroundColor 属性设置为 clear。这意味着视图的背景将是透明的,并且不会有实色背景;这样,任何下层的视图或图形都可以显示出来。

  • 我们创建一个具有指定size属性的SKScene实例,该属性确定烟雾效果的大小,以及背景color值为clear,这样烟雾效果的背景将是透明的。

  • 接下来,我们使用在Smoke.sks文件中定义的粒子系统创建一个SKEmitterNode实例。使用guard let语句来检查文件是否正确加载,如果没有,则返回一个空的SKView

  • 然后,我们将烟雾效果放置在场景的中心;将混合模式设置为SKBlendMode.screen,这将使烟雾与背景混合;设置烟雾粒子的初始缩放,这将使粒子更小、更细;并设置烟雾粒子缩放的范围。

  • scene实例上调用addChild方法,传入smoke节点作为参数。这将粒子发射器节点作为子节点添加到场景中,意味着它将在场景中显示。

  • view实例上调用presentScene方法,传入scene实例作为参数。这将设置场景为当前视图显示的场景。

  • 然后,return关键字返回view实例。

  • 当视图需要更新时,会调用updateUIView函数,例如当视图的状态发生变化时。在这段代码中,我们将其留空,因为在我们这个例子中不需要它。

总结来说,这段代码创建并返回一个SKView实例,该实例显示一个 SpriteKit 粒子发射器作为 SwiftUI 视图。现在,我们有一个准备好的SmokeView来显示烟雾。

让我们进入ContentView并为场景添加一个背景,以及一个嘴巴里有烟斗的笑脸图片。然后,我们将调用我们刚刚创建的SmokeView将烟雾放入烟斗中。为此,修改ContentView如下:

struct ContentView: View {
    var body: some View {
        ZStack {
            ///adding the pipe image and setting the size 
              and scale to fit on it
            Image("pipe")
                .resizable().frame(width: 350, height: 350)
                .scaledToFit()
            ///calling and positioning the SmokeView
            SmokeView()
            .offset(x: -140, y: 105)
        }.background(Image("background"))
    }
}

我们在这里所做的是创建一个ZStack,并将我们的笑脸图片(称为pipe)放入场景中。然后,我们使用widthheight值为350点的值来调整其大小,并调用我们的SmokeView

接下来,我们将烟雾定位在管道正上方,使其看起来是从管道中冒出来的,这是通过offset修饰符实现的。最后,我们将背景直接添加到ZStack上,项目就完成了。

如果你运行项目,你会看到从管道中冒出的逼真的烟雾:

图 15.5:管道烟雾动画

图 15.5:管道烟雾动画

现在你已经知道了如何创建烟雾,并且在我们 SwiftUI 项目中有了 SpriteKit 粒子系统的基本操作基础,让我们继续巩固你所知道的知识,并再次使用Smoke模板,但改变值以创建不同的效果。

动画咖啡蒸汽

在这个下一个项目中,我们将修改粒子系统文件以创建可以用于制作蒸汽咖啡动画的蒸汽。我们还将查看一种技术,可以通过重叠图像将蒸汽直接放置在咖啡中。要开始,创建一个新的项目并将其命名为 Coffee,然后我们将继续创建 SpriteKit 粒子文件。

创建 Smoke SpriteKit 粒子文件

要创建文件,就像之前一样,按 Command + N,选择 SpriteKit 粒子文件 模板,然后再次选择粒子模板选项中的 Smoke(是的,又是 Smoke,但这次我们将使其看起来像蒸汽)。

现在,我们将修改各种属性以创建我们的动画。将你的 SKS 文件中的所有属性更改为以下图示:

图 15.6:咖啡蒸汽动画的属性

图 15.6:咖啡蒸汽动画的属性

所有这些属性都在之前的项目中解释过了。如果你对每个属性的作用不清楚,请回顾那个部分以进行复习。

创建 Coffee Steam 结构体

接下来,让我们回到 ContentView 文件,并开始组装项目。在之前的项目中,我们学习了如何在 SwiftUI 视图中使用 .sks 文件,我们在这里也将做几乎完全相同的事情。我们首先创建了一个单独的结构体,并使用 UIViewRepresentable 协议在 SwiftUI 视图中使用 .sks 文件。因此,在这个项目中,在 ContentView 之下添加以下结构体:

struct CoffeeSteam: UIViewRepresentable {
    func makeUIView(context: 
      UIViewRepresentableContext<CoffeeSteam>) -> SKView {
        let view = SKView(frame: CGRect(x: 0, y: 0, width: 
          400, height: 400))
        view.backgroundColor = .clear
        let scene = SKScene(size: CGSize(width: 400, 
          height: 600))
        ///set the scenes background color to clear - we 
          only want the particles seen
        scene.backgroundColor = UIColor.clear
        guard let steam = SKEmitterNode(fileNamed: 
          "CoffeeSteam.sks") else { return SKView() }
        steam.position = CGPoint(x: scene.size.width / 2, 
          y: scene.size.height / 2)
        /// set the blend mode - scale - range
        steam.particleBlendMode = .screen
        steam.particleScale = 0.01
        steam.particleScaleRange = 0.05
        ///add the smoke to the scene
        scene.addChild(steam)
        view.presentScene(scene)
        return view
    }
    func updateUIView(_ uiView: SKView, context: 
      UIViewRepresentableContext<CoffeeSteam>) {
      /// Update the steam in this function if you need to
    }
}

我在之前的项目中解释了这段代码,但我会再次讲解它,以帮助你巩固理解:

  • 代码首先定义了一个名为 CoffeeSteam 的自定义 SwiftUI 视图,用于显示我们在 CoffeeSteam.sks 文件中配置的蒸汽效果。该结构体符合 UIViewRepresentable 协议。这使得结构体可以用作 SwiftUI 视图。

  • 接下来,我们添加 makeUIView 方法,这是 UIViewRepresentable 协议要求的。它创建并返回一个 SKView,用于显示蒸汽效果。

  • 我们创建了一个具有指定框架(大小和位置)的 SKView 实例。这个框架用于确定蒸汽效果的大小。

  • 我们将 SKView 实例的 backgroundColor 属性设置为 clear。这意味着视图的背景将是透明的,并且不会有实色的背景;这样,任何下层的视图或图形都可以显示出来。

  • 然后,我们创建一个指定大小的 SKScene 实例,用于确定蒸汽效果的大小,并将背景颜色设置为 clear;这样,蒸汽效果的背景也将是透明的。

  • 接下来,我们使用在 CoffeeSteam.sks 文件中定义的粒子系统创建一个 SKEmitterNode 实例。guard let 语句用于检查文件是否正确加载,如果没有,则返回一个空的 SKView

  • 然后,我们将蒸汽定位在场景的中心;将蒸汽的混合模式设置为 SKBlendMode.screen,这将有助于使蒸汽与背景混合;设置蒸汽粒子的初始缩放以帮助它们看起来像蒸汽效果;然后,我们设置烟雾粒子的缩放范围。(再次参考之前的项目,我在 SKS 文件中定义了每个这些属性字段)。

  • scene 实例上调用 addChild 方法,传入 steam 节点作为参数。这会将粒子发射器节点作为子节点添加到场景中,意味着它将在场景中显示。

  • view 实例上调用 presentScene 方法,传入 scene 实例作为参数。这会将场景设置为当前视图显示的场景。

  • 然后,return 关键字返回 view 实例。

  • 当视图需要更新时,会调用 updateUIView 函数,例如当视图的状态发生变化时。在这段代码中,我们将其留空,因为在我们这个例子中不需要它。

因此,所有这些代码创建并返回一个显示 SpriteKit 粒子发射器作为 SwiftUI 视图的 SKView 实例。注意,我在这里设置了一些属性?你可以通过调整 SKS 文件中的值来设置属性,或者你可以在这里通过代码设置它们;然而,记住当你通过代码设置时,它们会覆盖 SKS 文件中设置的任何内容。

完成 ContentView

现在,我们可以在 ContentView 中进行一些工作,并显示冒着热气的咖啡杯。如果你还没有这样做,请将资产拖放到 GitHub 上 Chapter 15 文件夹中名为 Coffee Steam 的资产库中。

让我们修改 ContentView 以使其看起来如下:

struct ContentView: View {
  var body: some View {
    ZStack {
        Image("background")
            .resizable().frame(width: 600, height: 900)
            .aspectRatio(contentMode: .fit)
        ZStack {
            ///adding the whole cup
            Image("cup")
                .resizable().frame(width: 350, height: 300)
                .aspectRatio(contentMode: .fit)
            ///calling and positioning the SmokeView
            CoffeeSteam().offset(x: 15, y: 80)
            ///adding the altered cup
            Image("cup 2")
                .resizable().frame(width: 350, height: 300)
                .aspectRatio(contentMode: .fit)
        }.offset(y: 250)
    }
  }
}

代码声明了一个 ZStack,它将包含我们的视图。在 ZStack 内部,我们添加了一个背景图片,调整了其大小,并设置了其宽高比。

然后,代码声明了另一个 ZStack,我们在其中放置了第一个名为 cup 的图片,调整了其大小并设置了其宽高比。

接下来,我们调用了 CoffeeSteam 结构体,并稍微偏移了一下,使其位于杯子的中心。

之后,我们添加了 cup 2 图片。这个第二张图片用于使蒸汽看起来像是从杯子中心冒出来的。所以,我们实际上是在两个咖啡杯图片之间夹着动画蒸汽,其中一个杯子的图片上有一个小缺口;当我们在这两个图片之间放置蒸汽时,我们可以创建一个看起来像是从咖啡杯内部发出的蒸汽效果。

最后,我在 ZStack 上调用了 offset 修饰符,将所有内容定位在 y 轴上。

现在,运行项目,你将看到蒸汽效果正从咖啡的表面冒出来。同时注意,当蒸汽上升时,杯子的背面会因蒸汽而闪闪发光,就像在真实的咖啡杯中一样。

图 15.7:一杯冒着热气的咖啡

图 15.7:一杯冒着热气的咖啡

现在,我们已经使用Smoke粒子模板完成了两个项目——创建管道烟雾和咖啡蒸汽——并且我们了解了如何以非常不同的方式操纵粒子。让我们继续前进,进入下一个项目,我们将使用不同的粒子系统:火焰。

火箭火焰动画

SpriteKit 火焰粒子模板生成的粒子通常是橙色、黄色和红色的阴影,给人一种发光的火星和火焰的印象。粒子也可能有一定的透明度,以模仿真实火焰的闪烁和移动特性。在行为方面,粒子被设计成以一定程度的随机性向上移动,代表热空气和火焰的移动。

然而,我们不会创建一些简单的火焰,而是要动画一个火箭!

Command + N,然后选择Rocket。Xcode 将创建你可以看到在编辑器中运行的火焰粒子。

现在,让我们创建一个 SwiftUI 视图,将这个.sks文件引入我们的 SwiftUI 项目中。

添加 FireView

要创建视图,按Command + N,创建一个SwiftUIView文件。然后,将其命名为FireView。然后,导入 SpriteKit,并在文件顶部添加以下代码:

struct FireView: UIViewRepresentable {
    func makeUIView(context: 
      UIViewRepresentableContext<FireView>) -> SKView {
        let view = SKView(frame: CGRect(x: 0, y: 0, width: 
          400, height: 400))
        view.backgroundColor = .clear
        scene.backgroundColor = UIColor.clear
        guard let fire = SKEmitterNode(fileNamed: 
          "Fire.sks") else { return SKView() }
        fire.position = CGPoint(x: scene.size.width / 2, y: 
          scene.size.height / 2)
        ///use the particlePositionRange property to 
          constrain the fire particles so they are not so 
          wide and can fit under the rockets exhaust
        fire.particlePositionRange = CGVector(dx: 5, dy: 0)
        ///add the fire to the scene
        scene.addChild(fire)
        view.presentScene(scene)
        return view
    }
    func updateUIView(_ uiView: SKView, context: 
      UIViewRepresentableContext<FireView>) {
        /// Update the fire in this function if you need to
    }
}

我不会详细讲解这段代码,因为我们已经做过类似的事情,但你可以参考之前的 SpriteKit 项目,那里有所有的解释以及我们如何使用UIViewRepresentable协议。

如果你运行预览中的代码,你会看到它运行得很好,火焰的尺寸正确,可以放在火箭下面。然而,请注意火焰是倒置的。让我们看看我们如何修复它,使其看起来像火箭下面的正确推力。

添加 RocketView

现在,让我们创建一个额外的视图,我们可以用它来组合火箭和火焰,然后旋转火焰,使其在火箭下方正确对齐。按Command + N,创建一个新的 SwiftUI 视图文件,并将其命名为RocketView。然后,在结构体内部添加以下代码:

struct RocketView: View {
        @State private var rocketAndFireOffset: CGFloat = 0
        var body: some View {
            ZStack {
                FireView().rotationEffect(Angle(degrees: 
                  180.0)).offset(y: 60)
                     ///move the fire upwards by changing y 
                       offset
                    .offset(x: 0, y: -rocketAndFireOffset)
                    /// position the fire at the bottom 
                      center of the screen
                    .position(x: 
                      UIScreen.main.bounds.width/2, y: 
                      UIScreen.main.bounds.height)
                Image("rocket")
                    .resizable().aspectRatio(contentMode: 
                      .fit).frame(width: 100, height: 200)
                     ///move the rocket upwards by changing 
                       y offset
                    .offset(x: 0, y: -rocketAndFireOffset)
                    ///position the rocket at the bottom 
                      center of the screen
                    .position(x: 
                      UIScreen.main.bounds.width/2, y: 
                      UIScreen.main.bounds.height)
                ///rotate the fire and offset it so its 
                  under the bottom of the rocket
            } .animation(Animation.linear(duration: 
              8).repeatForever(autoreverses: false),value: 
              rocketAndFireOffset) // increase the duration 
              of the animation
                .onAppear {
                    rocketAndFireOffset = 
                      UIScreen.main.bounds.height * 1.3 // 
                      move the rocket off the top of the 
                      screen, by increasing the offset
            }
        }
    }

首先,我们创建一个rocketAndFire偏移变量,我们可以用它来在屏幕上向上移动火箭和火焰。

然后,在ZStack内部,我们将FireView旋转 180°,并在y-轴上偏移,这使得火焰从火箭延伸出去,我们将很快添加它。然后,通过改变y-offset,我们向上动画火焰,并使用UIScreen属性在x-和y-轴上将火焰定位在屏幕中央。

然后,我们可以添加rocket图像,调整其大小,并通过使用rocketAndFire变量向上动画它。接下来,我们只需使用UIScreen属性将火箭定位在屏幕中央。

最后,我们可以将动画添加到ZStack的闭合括号中,这将同时作用于FireView和火箭图像。让我们给它设置8秒的持续时间,并使其无限重复,没有自动反转。

现在,我们可以在onAppear方法中通过更改rocketAndFire属性的值来发射火箭。当视图出现时,火箭将起飞并继续飞离屏幕顶部,然后从底部飞回来并继续飞行。

我们现在只需要添加背景!

在 ContentView 内部组合元素

ContentView内部,我们只需要很少的代码就能让我们的火箭进入太空:

struct ContentView: View {
    var body: some View {
        ZStack {
            ///add the RocketView to the scene
            RocketView()
        }.background(Image("background")
            .resizable()
            .scaledToFill().edgesIgnoringSafeArea(.all))
    }
}

就这样。在ZStack内部,我们添加了RocketView()到场景中,然后我们直接在ZStack上添加了背景图像。

在预览中查看,你会发现我们现在有一个在太空中飞行的火箭:

图 15.8:我们的火箭起飞

图 15.8:我们的火箭起飞

通过利用粒子系统的力量,我们能够创建一个逼真且动态的视觉效果,使我们的火箭栩栩如生。我们探讨了发射器的各种参数和属性,以微调推力的外观和感觉,并学习了如何将其与我们的 SwiftUI 视图集成。

看另一个例子,我们将回到之前已经看到过的另一个效果——雪——但修改它,使其产生暴风雪般的效果。

动画暴风雪

在下一个项目中,我们将创建一个暴风雪场景,并添加一个风效,使雪花从不同的方向飘落。我们还将使用这个风使树的树枝飘动,通过组合图像并对其动画化来实现。我们在第六章的呼吸花朵项目中创建了一个雪景,但这次,我们将使用粒子文件来创建这个雪景,这为我们提供了更多制作和控制雪的选项。

让我们从创建一个新的项目并命名为Snow开始。然后,我们将立即着手创建所需的 SKS 文件来制作雪——不过这次,我们将创建两个文件。

创建两个 Snow SpriteKit 粒子文件

对于这个项目,我们将创建两个 SpriteKit 文件——这两个文件都将来自Snow模板,但我们将使用不同的值,以便雪花从不同的方向和不同的速度飘落。

要创建第一个文件,按Command + N,选择Snow。现在,让我们配置粒子属性,以便我们可以创建一个漂亮的厚重雪景。使用以下图中的相同值:

图 15.9:Snow 文件的属性

图 15.9:Snow 文件的属性

这就创造了我们想要的所需雪景。为了帮助您设计独特的雪景,以下是一个调整指南:

  • 粒子寿命:将寿命设置得相对较长,这样粒子就可以在屏幕上停留一段时间

  • 粒子出生率:增加出生率,每秒生成更多粒子,这将创建一个更密集的下雪效果

  • 粒子大小:增加粒子的大小,使它们在屏幕上看起来更大、更突出

  • 粒子速度:降低粒子的速度,使它们下落得更慢、更温柔

  • 粒子颜色:将粒子的颜色改为白色或浅蓝色,使其看起来更像雪花

  • 发射器形状:将发射器形状改为矩形或线条,使下雪看起来更自然

  • 发射器位置:调整发射器的位置,从屏幕顶部开始下雪

如果你查看编辑器中的雪,你可以看到它有一个相当强烈的效果,这正是我们场景所需要的。现在,让我们创建第二个文件,并为雪设置不同的值;然后,我们将这两个 SKS 文件合并成一个 SwiftUI 视图,我们可以使用它来创建一个漂亮的暴风雪效果。

按照相同的步骤创建第二个文件,但这次将其命名为Blustery。现在,让我们将属性更改为以下值:

图 15.10:Blustery 文件的属性

图 15.10:Blustery 文件的属性

这个新文件有不同的出生率角度加速度值,使雪从不同的方向吹来,并且稍微提升一些,就像被风吹起一样。当我们合并这两个 SKS 文件时,这将创建一个很好的暴风雪效果。

因此,我们现在就做吧;我们需要创建一个结构体,可以将这两个文件放在一起。

创建一个结合两个 SKS 文件视图

ContentView文件内部工作,首先,添加 SpriteKit 导入。然后,在ContentView结构之后创建一个新的结构体SnowView。(你可以在项目中创建单独的文件,并在项目导航器中保持整洁,但为了这个项目,我打算将剩余的代码放入ContentView文件中。)

创建了SnowView后,在它内部添加以下代码:

struct SnowView: UIViewRepresentable {
  func makeUIView(context: 
    UIViewRepresentableContext<SnowView>) -> SKView {
      let view = SKView(frame: CGRect(x: 0, y: 0, width: 
        400, height: 400))
      view.backgroundColor = .clear
      let scene = SKScene(size: CGSize(width: 500, 
        height: 800))
      scene.backgroundColor = UIColor.clear
      guard let snow = SKEmitterNode(fileNamed: 
        "Snow.sks") else { return SKView() }
      guard let blustery = SKEmitterNode(fileNamed: 
        "Blustery.sks") else { return SKView() }
      //snow sks file
      snow.position = CGPoint(x: scene.size.width / 2, y: 
        scene.size.height / 2)
      ///use the particlePositionRange property to spread 
        the snow particles on the screen for the x and y 
        axis
      snow.particlePositionRange = CGVector(dx: 500, dy: 
        900)
      //blustery sks file
      blustery.position = CGPoint(x: scene.size.width / 2, 
        y: scene.size.height / 2)
      ///use the particlePositionRange property to spread 
        the snow particles on the screen for the x and y 
        axis
      blustery.particlePositionRange = CGVector(dx: 500, 
        dy: 900)
      ///add the snow to the scene
      scene.addChild(snow)
      scene.addChild(blustery)
      view.presentScene(scene)
      return view
  }
  func updateUIView(_ uiView: SKView, context: 
    UIViewRepresentableContext<SnowView>) {
      /// Update the snow in this function if you need to
  }
}

代码使用position属性定位了两个节点,即snow节点和blustery节点,并在两个节点上设置了particlePositionRange属性。particlePositionRange是粒子位置允许的随机值范围。最后,就像我们在其他粒子文件中所做的那样,我们使用addChild函数将它们添加到场景中,并传入我们想要添加到场景中的视图。

现在,我们可以在ContentView中调用这个结构来查看暴风雪,实际上,我们还可以添加一个背景雪景:

struct ContentView: View {
    var body: some View {
        ZStack {
            Image("background")
                .resizable().frame(width: 600, height: 900)
                .aspectRatio(contentMode: .fit)
            SnowView()
        }
    }
}

现在,运行预览并查看暴风雪效果:

图 15.11:我们的暴风雪场景

图 15.11:我们的暴风雪场景

现在,让我们继续进行项目,并添加另一个动画。对于这个,我们想要让树的树枝随风飘动。

动画树树枝

为了使树枝动画,我们将所有代码包裹在几个 ZStack 中。然后,我们将使用一个雪树枝的图像,通过 ForEach 循环多次复制它,将树枝图像放置在树的某个部分上,并在这些图像的每个轴上添加旋转动画。我们还将随机化动画,使其不遵循固定模式。

让我们先创建一个结构体来保存这些视图:

struct Branches: View {
 var body: some View {
     }
}

接下来,让我们添加我们需要跟踪动画的变量并设置一些初始值:

   @State private var anglesX = Double
    @State private var anglesY = Double
    @State private var anglesZ = Double
    @State private var positions = CGPoint
    @State private var durations = Double

这段代码在 SwiftUI 视图中定义了五个状态属性。

anglesXanglesYanglesZ 属性是长度为 25Double 值数组。这些数组分别用于存储围绕 xyz 轴的旋转角度。repeating: 参数用于将每个数组中的所有值初始化为 0。

positions 属性是一个包含 CGPoint 值的数组,长度为 25。这个数组用于存储视图的位置。repeating: 参数用于将数组中的所有值初始化为零点(0, 0)。

durations 属性是一个长度为 25Double 值数组。这个数组用于存储与每个视图相关的动画持续时间。repeating: 参数用于将数组中的所有值初始化为 0。

正如我们所见,@State 属性包装器用于使这些数组成为视图的可变状态属性。这意味着每当任何数组被修改时,SwiftUI 将自动重新渲染视图以反映这些更改。

接下来,让我们进入 body 属性并添加一个包含我们想要操作和复制的图像以及我们将要放在其上的动画的 ZStack

ZStack {
    ForEach(0..<8) { index in
         Image("branch")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .rotationEffect(Angle(degrees: anglesX[index]))
            .rotationEffect(Angle(degrees: anglesY[index]), 
              anchor: .center)
            .rotationEffect(Angle(degrees: anglesZ[index]), 
              anchor: .center)
            .position(positions[index])
            .frame(width: 200, height: 700)
            .animation(
                Animation.easeInOut(duration: 
                  durations[index])
                    .repeatForever(autoreverses: true), 
                      value: anglesX)
            .onAppear {
                anglesX[index] = Double.random(in: 2...4)
                anglesY[index] = Double.random(in: 2...3)
                anglesZ[index] = Double.random(in: 1...3)
                positions[index] = CGPoint(x: 
                  CGFloat.random(in: 0...10), y: 
                  CGFloat.random(in: 0...5))
                durations[index] = Double.random(in: 3...5)
            }
    }
} .offset(x: 50, y: 200)

这里我们要做的是。我们首先使用一个 ZStack,这样我们就可以将视图堆叠在一起。接下来是一个 ForEach 循环,它遍历从 0 到 7 的整数范围;index 参数用于访问循环中的当前迭代值。

然后,我们添加分支图像和以下修饰符:

  • resizable 修饰符调整图像的大小。

  • rotationEffect(_:) 修饰符用于围绕 xyz 轴旋转图像。旋转角度由 anglesXanglesYanglesZ 数组中的相应值指定。anchor 参数用于指定旋转中心。在这种情况下,.center 用于 anglesYanglesZ 的旋转,因此图像围绕其中心点旋转。

  • position(_:) 修饰符用于在屏幕上定位图像。positions 数组根据索引存储每个图像实例的位置。

  • frame(width:height:) 修饰符设置图像的大小。

  • animation(_:, value:)修饰符用于动画化图像的旋转。动画的持续时间基于存储在durations数组中的值,而value参数指定了每当anglesX数组发生变化时,动画应该重新评估。

  • onAppear修饰符用于在屏幕上出现图像时随机生成anglesXanglesYanglesZ位置和durations数组的值。

  • 最后,offset修饰符在x轴上应用了50点的偏移量,在y轴上应用了200点的偏移量到整个ZStack,这样就将树枝定位在树的中间右侧部分。

总结一下,我们所做的是创建八个覆盖着雪的树枝的图像视图,每个视图都有不同的旋转、位置、持续时间和动画。这些视图在ZStack中堆叠在一起,并应用偏移量以将它们放置在树上。

现在,我想将ZStack复制几次,这样我们就可以用雪枝覆盖整个树,而不仅仅是树的一部分。所以,我们将再创建四个ZStack,但使用略有不同的值,以使风吹效果看起来更随机和自然:

ZStack {
    ForEach(0..<10) { index in
        Image("branch")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .rotationEffect(Angle(degrees: anglesX[index]))
            .rotationEffect(Angle(degrees: anglesY[index]), 
              anchor: .center)
            .rotationEffect(Angle(degrees: anglesZ[index]), 
              anchor: .center)
    .position(positions[index])
            .frame(width: 200, height: 700)
            .offset(x: 50, y: 200)
            .animation(
                Animation.easeInOut(duration: 
                  durations[index])
                    .repeatForever(autoreverses: 
                      true),value: anglesY)
            .onAppear {
                anglesX[index] = Double.random(in: 3...4)
                anglesY[index] = Double.random(in: 2...5)
                anglesZ[index] = Double.random(in: 1...4)
                positions[index] = CGPoint(x: 
                  CGFloat.random(in: 0...10), y: 
                  CGFloat.random(in: 0...14))
                durations[index] = Double.random(in: 2...6)
            }
    }
}.offset(x: -80, y: -156)
  ZStack {
    ForEach(0..<15) { index in
      Image("branch")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .rotationEffect(Angle(degrees: anglesX[index]))
          .rotationEffect(Angle(degrees: anglesY[index]), 
            anchor: .center)
          .rotationEffect(Angle(degrees: anglesZ[index]), 
            anchor: .center)
          .position(positions[index])
          .frame(width: 200, height: 700)
          .offset(x: 50, y: 200)
          .animation(
              Animation.easeInOut(duration: 
                durations[index])
                  .repeatForever(autoreverses: true)
              ,value: anglesZ)
          .onAppear {
              anglesX[index] = Double.random(in: 1...3)
              anglesY[index] = Double.random(in: 2...4)
              anglesZ[index] = Double.random(in: 3...6)
              positions[index] = CGPoint(x: 
                CGFloat.random(in: 0...10), y: 
                CGFloat.random(in: 0...8))
              durations[index] = Double.random(in: 4...6)
          }
  }
}.offset(x: -120, y: 0)

    ZStack {
      ForEach(0..<7) { index in
        Image("branch")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .rotationEffect(Angle(degrees: anglesX[index]))
          .rotationEffect(Angle(degrees: anglesY[index]), 
            anchor: .center)
          .rotationEffect(Angle(degrees: anglesZ[index]), 
            anchor: .center)
          .position(positions[index])
          .frame(width: 200, height: 700)
          .offset(x: 50, y: 200)
          .animation(
              Animation.easeInOut(duration: 
                durations[index])
                  .repeatForever(autoreverses: true)
              ,value: anglesX)
          .onAppear {
              anglesX[index] = Double.random(in: 1...3)
              anglesY[index] = Double.random(in: 2...3)
              anglesZ[index] = Double.random(in: 3...5)
              positions[index] = CGPoint(x: 
                CGFloat.random(in: 0...10), y: 
                CGFloat.random(in: 0...12))
              durations[index] = Double.random(in: 4...6)
          }
  }
  }.offset(x: -100, y: 160)

  ZStack {
    ForEach(0..<7) { index in
      Image("branch")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .rotationEffect(Angle(degrees: anglesX[index]))
          .rotationEffect(Angle(degrees: anglesY[index]), 
            anchor: .center)
          .rotationEffect(Angle(degrees: anglesZ[index]), 
            anchor: .center)
          .position(positions[index])
          .frame(width: 180, height: 700)
          .offset(x: 50, y: 200)
          .animation(
              Animation.easeInOut(duration: 
                durations[index])
                  .repeatForever(autoreverses: true)
              ,value: anglesY)
          .onAppear {
              anglesX[index] = Double.random(in: 0...2)
              anglesY[index] = Double.random(in: 0...3)
              anglesZ[index] = Double.random(in: 0...1)
              positions[index] = CGPoint(x: 
                CGFloat.random(in: 0...10), y: 
                CGFloat.random(in: 0...12))
              durations[index] = Double.random(in: 3...6)
          }
  }
  }.offset(x: 10, y: 100)

所有这些ZStack基本上都做同样的事情,只是代码使用了不同的值。例如,在ForEach循环中,我们使用了一个不同的值范围,我们还改变了xyz轴的随机值,以及positionduration值。此外,每个ZStack都有自己的onAppear修饰符,因此它可以以不同的速度和不同的轴随机移动其分支集。

现在,让我们在ContentView内部调用这个结构。我们将在调用SnowView之前调用它;这样,雪就会出现在树图像的上方:

struct ContentView: View {
    var body: some View {
        ZStack {
            Image("background")
                .resizable().frame(width: 600, height: 900)
                .aspectRatio(contentMode: .fit)
            Branches()
            SnowView()
        }
    }
}

现在,运行代码并查看树枝在雪落下时随风摇曳的真正酷炫的动画。

玩转我们在各种参数中使用的所有值,并根据自己的喜好进行定制。也许你想要更少的分支以使树看起来更细,也许你想要风刮得更强,或者也许你想要在背景图像中动画化所有树木...你可以更改所有设置来实现这一点。

让我们继续探索一些有趣的动画。接下来,我们将查看雨粒子系统。

动画化雨

在我们的下一个项目中,让我们来一场雨。我们将通过从粒子模板创建雨,并使其像暴风雨中那样弹跳在地面上来创建一个逼真的效果。我们还将创建一个水坑,它会微妙地生长和收缩,看起来像是对外部下落的雨做出反应,并且它看起来也会像水一样,因为我们将在上面添加一些混合选项,使其看起来半透明,以达到一种壮观的效果,甚至可以看到其下的地面。

创建雨的 SpriteKit 粒子文件

让我们开始吧。你现在知道如何做了——创建一个 SKS 文件,选择Rain。现在,配置文件以具有以下属性:

图 15.12:雨动画的属性

图 15.12:雨动画的属性

使用Rain粒子模板可以直接获得雨效果,但通过调整出生率生命周期选项,我们可以改变我们想要的雨量大小。

现在,让我们创建另一个新文件,这次是一个 SwiftUI 文件,我们将使用它将雨效果引入 SwiftUI 视图。

创建雨滴

要创建 SwiftUI 文件,请按Command + N,并将此文件命名为DropView。我们将添加到该文件中的代码将负责创建雨滴与地面之间的碰撞效果。我们还将调整不透明度和模糊度,以真正帮助它融入场景。

现在,让我们在DropView结构体内部添加以下代码:

struct DropView: View {
  @State private var dropScale: CGFloat = 0.1
  @State private var xOffsets = (0..<300).map { _ in 
    CGFloat.random(in: -150...UIScreen.main.bounds.width)}
  @State private var yOffsets = (0..<240).map { _ in 
    CGFloat.random(in: UIScreen.main.bounds.height/5...
    UIScreen.main.bounds.height)}
  @State private var durations = (0..<150).map { _ in 
    Double.random(in: 0.3...1.0)}
  var body: some View {
      //Color.clear.edgesIgnoringSafeArea(.all)
          ForEach(0..<150) { index in
              Circle()
                  .fill(Color.white)
                      .opacity(0.6)
                    .blur(radius: 3)
                  .frame(width: 15, height: 15)
                  .scaleEffect(dropScale)
                  .rotation3DEffect(Angle(degrees: 80.0), 
                    axis: (x: 1, y: 0, z: 0))
                  .offset(x: xOffsets[index] - 140, y: 
                    yOffsets[index])
                  .animation(Animation.easeInOut(duration: 
                    durations[index]).repeatForever
                    (autoreverses: true), value: dropScale)
                  .onAppear {
                      dropScale = 0.8
                  }
          }
  }
}

这里有很多内容,让我们逐行分析。

我们需要四个State变量,每个变量都有特定的任务:

  • 第一个变量是CGFloat类型,初始值为0.1。这个变量用于控制雨滴的缩放。

  • 接下来的两个变量创建CGFloat值的数组,存储在xOffsetsyOffsets状态变量中。使用map方法生成随机CGFloat值,并使用CGFloat.random(in:)方法在给定范围内生成一个随机的CGFloat值。简而言之,这些变量用于为每个雨滴设置随机的x-和y-坐标位置。

  • 最后一个变量是durations变量,它随机化动画的持续时间。

让我们更仔细地看看map方法,因为它被我们的三个属性使用。map方法是 Swift 中的一个高阶函数,它将一个值数组转换为一个新的不同值的数组。它接受一个闭包表达式作为参数,该闭包表达式对原始数组中的每个元素执行,并为该元素返回一个新值。结果数组是所有这些新值的组合。

在代码中,map方法被用来将 0 到<300 之间的整数范围转换为CGFloat值的数组,通过使用CGFloat.random(in:)方法为范围内的每个元素生成一个随机值。传递给map方法的闭包表达式接受一个参数,_,它是范围当前元素的占位符,并返回一个由CGFloat.random(in: -150...UIScreen.main.bounds.width)生成的新的CGFloat值。map方法将这些新值组合成一个单一的数组,然后将其分配给xOffsets变量。

map 方法是 Swift 中一个重要且多功能的函数,因为它允许你轻松地将数组值和序列转换为新的数组和序列。此外,map 方法通常与过滤和归约等其他函数式编程技术结合使用,以更高效、更易于维护的方式在数组和序列上执行复杂操作。

现在,让我们进入 body 属性,看看代码在那里做了什么。

我们使用 ForEach 循环遍历从 0 到 <150 的范围,并在每次迭代中创建一个 Circle 视图——这个圆圈将代表雨滴。然后,我们将 Circle 视图的 fill 颜色设置为 whitewidthheight 值设置为 15,并应用由 dropScale 变量确定的 scaleEffect

接下来,我们将 rotation3DEffect 应用到 Circle 视图上,在 x 轴上有一个 80 度的角度;我们想要旋转雨滴,使其看起来更像雨滴撞击地面。之后,我们使用 xOffsetsyOffsets 变量设置偏移量,其值由索引确定。

然后我们添加动画,将持续时间设置为由 index 常量确定,并将 autoreverse 设置为 true 以创建雨滴与地面的逼真碰撞效果。

最后,在 onAppear 修饰符中,我们将 DropScale 变量的值设置为 0.8

由于白色背景,运行预览可能会稍微困难一些,但让我们继续并稍后检查结果。

现在,让我们创建一个新的视图,它将创建一个可以动画并添加到场景中的水坑。

创建水坑

现在,让我们在我们的场景中添加一个水坑,我们将在一个新的 SwiftUI 视图文件 PuddleView 中完成。我将添加这个视图的所有代码,然后我们将审查其工作原理:

struct PuddleView: View {
  @State private var scaleX: CGFloat = 0.5
  @State private var scaleY: CGFloat = 0.5
  var body: some View {
    ZStack {
      Capsule()
          .fill(LinearGradient(gradient: Gradient(colors: 
            [.white,  .black,.gray, .white,.black]), 
            startPoint: .topLeading, endPoint:  
            .bottomTrailing))
          .opacity(0.5)
          .blur(radius: 5)
          .frame(width: 600, height: 500)
          .scaleEffect(x: scaleX, y: scaleY, anchor: 
        .center)
      .animation(Animation.easeInOut(duration: 
        8.0).repeatForever(autoreverses: true),value: 
        scaleX)
      //creates the ripple
          .overlay(
              Capsule()
                  .stroke(Color.gray, lineWidth: 5)
                  .opacity(0.5 )
                  .frame(width: 350, height: 200)
                  .offset(x: 0, y: -15)
                  .scaleEffect(x: scaleX + 0.03, y: scaleY 
                    + 0.03, anchor: .center)
                  .animation(Animation.easeInOut(duration: 
                    8.0).repeatForever(autoreverses: true), 
                    value: scaleY)
                  .onAppear {
                      scaleX = 0.54
                      scaleY = 0.6
                  }).rotation3DEffect(Angle(degrees: 81.0), 
                    axis: (x: 1, y: 0, z: 0))

  } .offset(x: -50, y: 300)
      .onAppear {
          scaleX = 0.55
          scaleY = 0.6
      }
  }
}

此代码定义了一个名为 PuddleView 的结构体,它有两个属性,scaleXscaleY,它们都是 CGFloat 类型的值。结构体的 body 属性定义如下:

  • 创建了一个 ZStack 视图,它将视图堆叠在一起。

  • ZStack 内部,创建了一个 Capsule 视图,它将是水坑的形状。Capsule 视图填充了颜色渐变,然后通过几个修饰符进行修改:

    • opacity 修饰符设置为 0.5,使其部分透明。

    • blur 修饰符设置为半径为 5 像素的模糊效果。

    • frame 修饰符将 width 属性设置为 600 像素,height 设置为 500 像素。

    • scaleEffect 修饰符设置为 xy 缩放值分别对应 scaleXscaleYanchor 参数设置为 .center,表示缩放应以胶囊的中心为基准。

    • animation 修饰符具有 scaleX 值,使用 easeInOut 时间函数和 duration 值为 8 秒,并设置为无限重复,带有 autoreverses

  • 然后,使用.overlay()创建一个新的Capsule视图,并将其放置在之前的视图之上。这个新的Capsule视图也通过几个修改器进行了修改:

    • stroke修改器添加了一个线宽为5像素的灰色描边。

    • opacity修改器设置为0.5,使其部分透明。

    • frame修改器将width属性设置为350像素,height设置为200像素。

    • offset修改器将胶囊稍微向上移动。

    • scaleEffect修改器设置了xy的缩放值,其中scaleX设置为+ 0.03scaleY也设置为+ 0.03anchor参数设置为.center,表示缩放应该以胶囊为中心。

    • animation修改器有一个scaleY值,使用easeInOut时间函数和duration值为8秒,设置为无限重复,并带有autoreverses

    • onAppear修改器将scaleXscaleY的初始值分别设置为0.540.6

    • rotation3DEffect修改器在x轴上设置了一个81度的角度。

  • 然后使用.offset(x: -50, y: 300).onAppear { scaleX = 0.55; scaleY = 0.6 }修改ZStack视图。这使组视图向左移动 50 像素,向下移动 300 像素。此代码块在视图出现时执行,并将scaleXscaleY的初始值分别设置为0.550.6

总结来说,这段代码通过使用部分透明且模糊的渐变填充以及添加波纹效果来创建一个看起来像水坑的视图。波纹效果是通过将缩放效果应用于第二个胶囊视图,并动画化scaleXscaleY属性来实现的,使其看起来在移动。波纹效果也在x轴上旋转,使其更加有趣。

将所有内容整合在一起

在完成PuddleView后,让我们填写ContentView并查看动画。添加以下代码以修改ContentView

import SpriteKit
import SwiftUI
struct ContentView: View {
    var body: some View {
        ZStack {
            Image("street")
                   .resizable()
                   .scaledToFill()
            PuddleView()//.blendMode(.hardLight)
            RainView()
            DropView()
            RainView()
        }.edgesIgnoringSafeArea(.all)
    }
}

ContentView中,我们添加了一张街道的图片,调整了大小和比例,然后调用了PuddleView。在PuddleView中,blendMode设置为hardLight。尝试不同的混合模式选项以获得不同的外观和效果,但我认为硬光效果迄今为止提供了最佳的水效果,创造出一种类似玻璃的外观,非常适合制作可以看到地面的一部分水坑。

接下来,我调用了RainView来添加雨,然后调用DropView来添加雨滴撞击地面的碰撞效果,然后再次调用Rainview以添加更多雨。这是结果:

图 15.13:雨滴

图 15.13:雨滴

这创建了一个很好的效果,雨滴与地面甚至与水坑相撞。

让我们继续,看看魔法粒子模板,我们将了解如何使用图像来制作粒子。

动画化魔法棒

在这个项目中,我们将使用Magic粒子系统,并将从魔杖尖端显示这种魔法。您将能够用手指在屏幕上移动魔杖,当它移动时,魔法将从其尖端散发出来。我们还将有一个包括墓碑的墓地背景,当您点击墓碑时,一个骷髅将从它升起。

那么,让我们开始我们的神秘动画吧。创建一个新的项目,并将其命名为Magic。接下来,通过从 GitHub 上的Chapter 15 | Magic文件夹中将资源拖动到资产库中,为该项目添加资源。然后,我们可以创建我们的粒子文件。

创建魔法 SpriteKit 粒子文件

如我们之前所做的那样,创建一个新的 SpriteKit 粒子文件,但选择Magic。现在,让我们这次做一些不同的事情——在属性面板中的纹理字段中,选择您放入资产库中的星星图像。我们将基于该图像创建粒子系统,因此所有粒子都将变成星星。接下来,更改其余字段,使其值看起来如下:

图 15.14:魔法动画的属性

图 15.14:魔法动画的属性

注意颜色渐变字段。它选择了三种颜色。如果您想为颜色渐变字段选择颜色,只需在颜色选择器上点击任何位置,然后会出现一个弹出颜色框,允许您选择颜色。我在这里选择了三种颜色:红色将是动画的中心,绿色将围绕它,第三种颜色是黄色,它将是魔法动画的外部部分。

接下来,我们希望将这个Magic.sks文件作为 SwiftUI 视图可用。所以,让我们创建一个新的 Swift 文件,并将其命名为MagicView。然后,将以下代码添加到文件中:

import SwiftUI
import SpriteKit
struct MagicView: UIViewRepresentable {
    func makeUIView(context: 
      UIViewRepresentableContext<MagicView>) -> SKView {
        let view = SKView(frame: CGRect(x: 0, y: 0, width: 
          400, height: 400))
        view.backgroundColor = .clear
        let scene = MagicScene(size: CGSize(width: 900, 
          height: 600))
        scene.backgroundColor = UIColor.clear
        scene.scaleMode = .aspectFill
        view.presentScene(scene)
        return view
    }
    func updateUIView(_ uiView: SKView, context: 
      UIViewRepresentableContext<MagicView>) {
    }
}

我们在以前的项目中处理过这段代码,所以我们现在对它很熟悉。

代码定义了一个名为MagicView的结构体,该结构体符合 SwiftUI 中的UIViewRepresentable协议。makeUIView函数创建了一个具有给定框架大小的SKView实例,并将其背景颜色设置为clear

然后,它创建了一个MagicScene实例,指定其大小,将其背景颜色设置为clear,并将其缩放为aspectFill。最后,创建的场景在SKView上显示,并返回SKView实例。

updateUIView函数不会为我们执行任何操作,因为我们不需要更新任何内容,但它对于UIViewRepresentable协议是一个必需的方法。

现在,代码将因为这一行而出错:let scene = MagicScene(size: CGSize(width: 900, height: 600))。这是因为我们创建了一个场景并将其设置为MagicScene类,但我们还没有创建一个MagicScene类。

那么,我们现在就来做这件事。创建MagicScene类,它将包含我们需要的属性和函数来操纵魔杖发出的魔法,以及使骨骼从地面升起。创建一个新的 Swift 文件,并将其命名为Magic。我将在这里放置所有代码,然后解释它是如何工作的:

import Foundation
import SwiftUI
import SpriteKit
class MagicScene: SKScene {
  var magic: SKEmitterNode!
  var wand: SKSpriteNode!
  override func touchesMoved(_ touches: Set<UITouch>, 
    with event: UIEvent?) {
      let touch = touches.first!
      let touchLocation = touch.location(in: self)
      wand.position = CGPoint(x: touchLocation.x - 30, y: 
        touchLocation.y + wand.frame.size.height / 2 - 20)
      ///make the skeleton appear
      if touchLocation.x < frame.size.width * 0.55 && 
        touchLocation.y < frame.size.height * 0.12 {
          let skeleton = SKSpriteNode(imageNamed: 
            "skeleton")
          skeleton.position = CGPoint(x: frame.size.width / 
            2 - 80, y: 175)
          skeleton.size = CGSize(width: skeleton.size.width 
            / 2, height: skeleton.size.height / 2)
          addChild(skeleton)
          let moveAction = SKAction.move(to: CGPoint(x: 
            frame.size.width / 2 - 50, y: frame.size.height 
            / 2), duration: 2.0)
          skeleton.run(moveAction)
      }
      wand.zPosition = 2
      let trail = SKEmitterNode(fileNamed: "Magic.sks")!
      trail.particlePositionRange = CGVector(dx: 5, dy: 5)
      trail.particleSpeed = 50
      trail.position = CGPoint(x: wand.position.x - 40, y: 
        wand.position.y + wand.frame.size.height / 2 + 
        trail.particlePositionRange.dy)
      addChild(trail)
      let fadeAway = SKAction.fadeOut(withDuration: 1.2)
      trail.run(fadeAway) {
          trail.removeFromParent()
      }
  }
  override func didMove(to view: SKView) {
      let stone = SKSpriteNode(imageNamed: "stone")
      stone.position = CGPoint(x: frame.size.width / 2.3, 
        y: frame.size.height / 2 - 150)
      stone.size = CGSize(width: 120, height: 175)
      stone.zRotation = CGFloat(Double.pi / 20)
      stone.zPosition = 2
         addChild(stone)
      guard let magic = SKEmitterNode(fileNamed: 
        "Magic.sks") else { return }
      magic.particlePositionRange = CGVector(dx: 5, dy: 5)
      magic.particleSpeed = 50
      addChild(magic)
      self.magic = magic
      wand = SKSpriteNode(imageNamed: "wand")
      wand.position = CGPoint(x: frame.size.width / 2, y: 
        frame.size.height / 3)
      wand.size = CGSize(width: 80, height: 180)
      addChild(wand)
  }
}

好的,让我们分析一下代码,看看它在做什么。

MagicScene类内部,声明了两个实例变量:

  • magicSKEmitterNode的一个实例,它是 SpriteKit 中的一个类,代表可以创建粒子的发射器。

  • wandSKSpriteNode的一个实例,它是 SpriteKit 中的一个类,代表一个纹理矩形。

MagicScene类覆盖了SKScene的两个方法:

  • 当用户在屏幕上移动手指时,会调用touchesMoved(_:with:)方法。在这个方法内部,魔杖精灵的位置被更新以跟随用户的触摸位置。如果触摸位置在屏幕的特定区域,则会创建一个骨骼精灵并动画化移动到屏幕上的特定位置,垂直向上。然后,创建一个新的SKEmitterNode实例并将其添加为MagicScene实例的子节点。这个发射器相对于魔杖精灵定位并发射我们在.sks文件中创建的粒子,并模拟魔法效果。经过 1.2 秒的持续时间后,发射器逐渐消失并被从场景中移除。

  • 当场景首次呈现时,会调用一次didMove(to:)方法。在这个方法内部,创建了一个石精灵并将其定位在屏幕上。然后,创建了一个SKEmitterNode实例并将其添加为MagicScene实例的子节点。这个发射器也将模拟魔法粒子。最后,创建了一个魔杖精灵并将其定位在屏幕上。

这就完成了magic.sks文件的代码。让我们继续前进,进入ContentView并添加一些代码,这样我们就能看到魔法效果。我们只需要添加墓地背景场景并调用MagicView。为此,将你的ContentView修改为以下内容:

struct ContentView: View {
    var body: some View {
    ZStack {
        Image("graveyard")
            .resizable()
            .scaledToFill().frame(width: 500, height: 900)
        MagicView()
      }
  }
}

用这段代码,项目就完成了。运行动画,开始施展魔法:

图 15.15:魔杖和墓地场景

图 15.15:魔杖和墓地场景

移动魔杖,看看从其尖端冒出的魔法,然后点击墓碑唤醒骨骼!

概述

SpriteKit 对物理和粒子系统的广泛支持,结合 SwiftUI 易于使用的界面和现代设计能力,你可以创建动态、引人入胜的动画,让你的应用栩栩如生。正如我们在这里所看到的,你可以创建烟雾、雨、火、雪和魔法,但还有更多粒子系统可以尝试和实验。

就像往常一样,根据你的喜好调整每个项目,并加入你独特的创造力和想法。如果你想的话,可以为每个项目的各个部分添加声音——例如,在魔杖项目中,当魔杖移动时,可以播放魔杖音效。通过调整值、更改图像或使用你现在拥有的工具构建更复杂的场景来增强动画。只需享受乐趣,因为效果仅限于你的想象力。

有了这些,我们已经完成了最终项目,也完成了最后一章。

在整本书中,我们深入探讨了隐式和显式动画,研究了它们之间的区别以及如何使用它们来实现不同的效果。随着我们在书中的进展,我们逐渐介绍了不同的修饰符和更具挑战性的动画技术,从基本的弹跳到更高级的动作。我们还构建了两个完整的游戏,你可以以许多不同的方式对其进行修改。

你现在拥有了知识、技能,以及对 SwiftUI 动画的更深入理解,能够实现一系列效果,将你的应用提升到下一个层次,并创造引人入胜且动态的用户体验。

祝你动画愉快!

posted @ 2025-10-24 10:07  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报