Swift

相关术语:

  • UIKit is the iOS user interface toolkit.
  • IB is Interface Builder.
  • CA is Core Animation. Like CALayer is a Core Animation data type responsible for managing the way your view looks. Core Animation handles animation.
  • CG is Apple’s Core Graphics framework. Like CGColor. Core Graphics handles drawing.
  • KVO is key-value observing.
  • NS is Next Step. Steve Jobs did lots.
  • VFL is a technique called Auto Layout Visual Format Language.
  • GCD is Grand Central Dispatch.
  • FIFO is First In, First Out.
  • MK is Mapkit.
  • NS came from the NeXTSTEP libraries Foundation and AppKit(those names are still used by Apple’s Cocoa frameworks).
  • UN is UserNotification.
  • CL is CoreLocation.
  • MC is Multipeer Connectivity.
  • CM is Core Motion.
  • LA is the Local Authentication framework.
  • HSB is Hue, Saturation and Brightness. Using this method of creating colors you specify values between 0 and 1 to control how saturated a color is (from 0 = gray to 1 = pure color) and brightness (from 0 = black to 1 = maximum brightness), and 0 to 1 for hue. “Hue” is a value from 0 to 1 also, but it represents a position on a color wheel, like using a color picker on your Mac. Hues 0 and 1 both represent red, with all other colors lying in between.
  • CK is CloudKit.
  • XCTest may be XCode Test.

String

A string is a collection of character.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let pizzaJoint = "café pesto"

// 获取首字母的String.index索引
let firstCharacterIndex = pizzaJoint.startIndex // 得到的是String.index类型
// 获取首字母偏移3的String.index索引
let fourthCharacterIndex = pizzaJoint.index(firstCharacterIndex, offsetBy: 3)
// 获取首字母偏移3的String.index索引所在位置的Character
let fourthCharacter = pizzaJoint[fourthCharacterIndex] // "é"

if let firstSpace = pizzaJoint.index(of: " ") {
let secondWordIndex = pizzaJoint.index(firstSpace, offsetBy: 1)
// 使用 Range of String.Index 来取得字符串的片段
let secondWord = pizzaJoint[secondWordIndex ..< pizzaJoint.endIndex] // "pesto"
// 没有secondWordIndex 直接..< pizzaJoint.endIndex 也行,一般默认都是从第一个开始
}

// 使用Array(String)将字符串Array化
let characterArray = Array(pizzaJoint) // ["c", "a", "f", "é", " ", "p", "e", "s", "t", "o"]

var s = pizzaJoint
// 使用String的insert(contentsOf: String)方法插入字符串
s.insert(contentsOf: "foo", at: s.firstIndex(of: " ")!) // "caféfoo pesto"


// func hasPrefix(String) -> Bool
// 查询前缀的方法,此处省略代码

// func hasSuffix(String) -> Bool
// 查询后缀的方法,此处省略代码

// func replaceSubrange(Range<String.Index>, with: Collection of Character)
s.replaceSubrange(..<s.endIndex, with: "new contents") // "new contents"

NSAttributedString

Attributed strings are made up of two parts: a plain Swift string, plus a dictionary containing a series of attributes that describe how various segments of the string are formatted.

NSAttributedString可以针对一串字符串,片段化地设置其的属性,而Label却只能整个设置。
我们在使用UILabel, UITextField, UITextView, UIButton, UINavigationBar等等支持text属性的情况下,更建议使用attributedText。

1
2
NSAttributedString(string: String, attributes: [NSAttributedString.Key: Any]?)

The examples are in codes below.

NSAttributedString.Key – 一般用法

NSAttributedString.Key is the attributes that you can apply to text in an attributed string.

1
2
3
4
5
6
7
8
9
10
11
import UIKit

var string = "This is a test string."

let attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.white,
.backgroundColor: UIColor.red,
.font: UIFont.boldSystemFont(ofSize: 36)
]

let attributedString = NSAttributedString(string: string, attributes: attributes)

效果是这样的:
NSAttributedString.Key
虽然我们可以在Label中设置字符串的属性,但你不能对该字符串的不同部分设置不同的属性,而NSAttributedString可以做到。

NSMutableAttributedString(string: String)

可更改属性的NSString,即使你使用let来定义,如下:

1
2
3
4
5
6
7
8
let string = "This is a test string"

let attributedString = NSMutableAttributedString(string: string)
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 8), range: NSRange(location: 0, length: 4))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 16), range: NSRange(location: 5, length: 2))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 24), range: NSRange(location: 8, length: 1))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 32), range: NSRange(location: 10, length: 4))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 40), range: NSRange(location: 15, length: 6))

NSMutableAttributedString

tips:两个NSAttributedString如何使用append相加?
答:无法相加。但是,一个可以设置成NSMutableAttributedString,去append另一个NSAttributedString即可。

NSAttributedString.Key – 的其他属性

There are lots of formatting options for attributed strings, including:

  • Set .underlineStyle to a value from NSUnderlineStyle to strike out characters.
  • Set .strikethroughStyle to a value from NSUnderlineStyle (no, that’s not a typo) to strike out characters.
  • Set .paragraphStyle to an instance of NSMutableParagraphStyle to control text alignment and spacing.
  • Set .link to be a URL to make clickable links in your strings.
1
attributedString.addAttribute(.link, value: "https://www.google.com", range: NSRange(location: 0, length: attributedString.length))

NSAttributedString.Key.link

案例-使段落文字居中,文字大小根据手机设置的字体自动匹配更改

1
2
3
4
5
6
7
8
9
10
11
12
func centeredAttributedString(_ string: String, fontSize: CGFloat) -> NSAttributedString {
// 设置字体和尺寸
var font = UIFont.preferredFont(forTextStyle: .body).withSize(fontSize)
// 让字体大小跟随用户调整的手机字体大小来改变
font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)
// 设置段落的格式
let paragraphStyle = NSMutableParagraphStyle()
// 让段落的格式居中
paragraphStyle.alignment = .center

return NSAttributedString(string: string, attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle, .font: font])
}

FileManager

枚举程序设备目录内的所有文件

1
2
3
4
5
6
7
let fm = FileManager.default
let path = Bundle.main.resourcePath!
let items = try! fm.contentsOfDirectory(atPath: path)

for item in items {

}

Bundle

Bundle.main.url(forResource:) – 仅通过文件名而返回该文件的url

Finding a path to a file is something you’ll do a lot, because even though you know the file is called “start.txt” you don’t know where it might be on the filesystem. So, we use a built-in method of Bundle to find it: path(forResource:). This takes as its parameters the name of the file and its path extension, and returns a String? – i.e., you either get the path back or you get nil if it didn’t exist.

1
2
3
4
5
6
7
// 假设要找寻到start.txt这个文件
if let startWordsURL = Bundle.main.url(forResource: "start", withExtension: "txt") {
// 若找到该start.txt的url后读取里面的文本内容
if let startWords = try? String(contentsOf: startWordsURL) {
// 省略后续代码
}
}

Bundle.main.urls(forResourcesWithExtension: String?, subdirectory: String?) – 从直接指定文件名及具体目录名称来找所有文件

如果我在某个项目目录中放了一些文件,这些文件都放在Cards.bundle目录的子目录Characters中,且不指定文件的名称和后缀:

1
2
3
4
5
6
7
8
9
let urls = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: "Cards.bundle/Characters")
/*
Optional([Dog.png --
file:///Users/vito/Library/Developer/CoreSimulator/Devices/428C06DC-515B-4870-BFC9-F8650821F110/data/Containers/Bundle/Application/D3A66460-669B-4539-BA09-DF69E0B9BE5D/Milestone-Project28-30-V3.app/Cards.bundle/Characters/, AdventurerGirl.png --
file:///Users/vito/Library/Developer/CoreSimulator/Devices/428C06DC-515B-4870-BFC9-F8650821F110/data/Containers/Bundle/Application/D3A66460-669B-4539-BA09-DF69E0B9BE5D/Milestone-Project28-30-V3.app/Cards.bundle/Characters/, TheBoy.png --
..............
..............
file:///Users/vito/Library/Developer/CoreSimulator/Devices/428C06DC-515B-4870-BFC9-F8650821F110/data/Containers/Bundle/Application/D3A66460-669B-4539-BA09-DF69E0B9BE5D/Milestone-Project28-30-V3.app/Cards.bundle/Characters/])
*/

subdirectory目录必须是详细具体的。

URL的lastPathComponent返回文件名

上例来说:

1
2
print(urls?[0].lastPathComponent)
// Dog.png

NSObject

NSObject is what’s called a universal base class for all Cocoa Touch classes. That means all UIKit classes ultimately come from NSObject, including all of UIKit.

Data

Data(contentsOf: URL)

从特定url取得数据

1
2
3
4
5
6
7
8
9
10
11
12
override func viewDidLoad() {
super.viewDidLoad()

// let urlString = "https://api.whitehouse.gov/v1/petitions.json?limit=100"
let urlString = "https://www.hackingwithswift.com/samples/petitions-1.json"

if let url = URL(string: urlString) {
if let data = try? Data(contentsOf: url) {
// we're OK to parse!
}
}
}

Data.write(to:)

下面是通过UIImagePickerController取得照片后写入本地的一个例子:
见 UIImagePickerController –> 一般示例代码 跳转到

Data 与 String 的转换

1
2
3
4
5
6
// 初始的字符串
let str = "This is a demo string."
// 转换成Data数据
let data = Data(str.utf8)
// Data数据再转换成字符串
let strFromData = String(decoding: data, as: UTF8.self)

UIView

UIView是UIKit框架里面最基础的视图类,UIView类定义了一个矩形的区域,并管理该区域内的所有屏幕显示。
UIView is the parent class of all of UIKit’s view types: labels, buttons, progress views, and more.
程序启动后,创建的第一个视图就是UIWindow,接着创建视图控制器的view,并把view放到UIWindow上,于是控制器的view就显示在屏幕上了。

所以我们可以这样使用UIView():

1
2
3
4
5
6
override func loadView() {
view = UIView()
view.backgroundColor = .white

// more code to come!
}

Previously we assigned a WKWebView instance directly as our view, meaning that it automatically took up all the space. Here, though, we’re going to be adding lots of child views and positioning them by hand, so we need a big, empty canvas to work with.

下面是在一个UIView()的基础上再添加一个UIView()

1
2
3
4
// buttonsView是为了后续添加大量UIButton进行显示的一个容器view
let buttonsView = UIView()
buttonsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(buttonsView)

tintColor

任何UIView子类的tintColor属性,可以改变应用在其上的颜色效果,但具体什么效果,取决于你应用在什么上面。
在navigationBar和tab bars上面,意味着改变button上的text和icons的颜色;
在text views上面,意味着改变被选择和高亮的text部分的颜色;
在progress bars上面,意味着改变它的track color(这是不是progress前半进程的颜色?)的颜色。
在tableView的cell上面,改变的就是在editing模式下,选择区域的颜色,具体见tableView的tintColor一块的笔记。

设置单个页面的tintColor

1
2
3
override func viewDidLoad() {
view.tintColor = UIColor.red
}

frame.size – 查看UIView的框架大小

view.frame.size.width
view.frame.size.height

layoutMargins属性 – 查看Margins的大小

通过查看一个view的layoutMargins属性,返回的结果是:
UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0)
这时候就可以查看或者修改 top/left/bottom/right 等的值了。

设置项目中所有views的tint – 需要在AppDelegate.swift中设置

AppDelegate.swift:

1
2
3
4
5
6
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
window?.tintColor = UIColor.red

return true
}

UITapGestureRecognizer – 点击响应事件

实例1:(拿UIImageView示例)

1
2
3
4
// 创建一个UITapGestureRecognizer的事件响应函数
@objc func imageViewTap(_ sender: UITapGestureRecognizer) {
print("This view is tapped")
}
1
2
// 创建UITapGestureRecognizer实例
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageViewTap))
1
2
3
4
// @IBOutlet var imageView: UIImageView!
// imageView已设置image
imageView.isUserInteractionEnabled = true // 允许用户交互
imageView.addGestureRecognizer(tapGesture)

实例2:(拿UILabel示例)

1
2
// 创建UITapGestureRecognizer实例
let tapGesture2 = UITapGestureRecognizer(target: self, action: #selector(imageViewTap))
1
2
3
4
// @IBOutlet var label: UILabel!
// label已设置text
label.isUserInteractionEnabled = true // 允许用户交互
label.addGestureRecognizer(tapGesture)

Adding a gesture recognizer to a UIView

向某个UIView添加手势识别
在一个Controller’s View中,我们想让某个UIView能够识别一个“pan gesture”(平移手势),我们可以在设置这个UIView的变量属性中,添加一个property observer,具体如下:

1
2
3
4
5
6
@IBOutlet weak var pannableView: UIView {
didSet {
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(ViewController.pan(recognizer:)))
pannableView.addGestureRecognizer(panGestureRecognizer)
}
}

UITapGestureRecognizer 的另一示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let recognizer = UITapGestureRecognizer(target: self, action: #selector(webViewTapped))
recognizer.delegate = self
webView.addGestureRecognizer(recognizer)

@objc func webViewTapped(_ recognizer: UITapGestureRecognizer) {
if let selectedWebView = recognizer.view as? WKWebView {
// selectWebView(selectedWebView) 自定义后续的处理方法
}
}

// you need to tell iOS we want these gesture recognizers to trigger alongside the recognizers built into the WKWebView
// 不设置这个方法的话,点击就不会有效果
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}

UIView的draw()作画机制

你可以自己在UIView中自己去作画,这时候就会用到draw(),即
override func draw(_ rect: CGRect) { }

简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PlayingCardView: UIView {

override func draw(_ rect: CGRect) {

let path = UIBezierPath()
path.addArc(withCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: 100.0, startAngle: 0, endAngle: 2.0*CGFloat.pi, clockwise: true)
path.lineWidth = 5.0
UIColor.green.setFill()
UIColor.red.setStroke()
path.stroke()
path.fill()

}
}

uiview_UIBezierPath

setNeedsDisplay() – 告诉系统重写界面

setNeedsDisplay() – Marks the receiver’s entire bounds rectangle as needing to be redrawn.
调用这个方法,实际上就是让这个UIView去执行override func draw(_ rect: CGRect) { }

setNeedsLayout() – 告诉系统subViews也需要重写

看到教程是setNeedsDisplay()setNeedsLayout()先后一起执行的。

1
2
3
4
5
6
var rank: Int = 5 {
didSet {
setNeedsDisplay()
setNeedsLayout()
}
}

调用这个方法,实际上就是让这个UIView去执行override func layoutSubviews() { }

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { }

It is called when the iOS interface environment changes. 比如手机的字体设置中换成了超大字体,那么程序就会调用该方法。

1
2
3
4
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
setNeedsDisplay()
setNeedsLayout()
}

UITableViewController

IndexPath(row: Int, section: Int)

let indexPath = IndexPath(row: 1, section: 0)

需要实现的方法

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { }

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { }

1
2
3
4
5
6
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// 在IB中的cell设置中必须注明Identifier是"Picture"
let cell = tableView.dequeueReusableCell(withIdentifier: "Picture", for: indexPath)
cell.textLabel?.text = pictures[indexPath.row]
return cell
}

如果tableView没有在storyBoard中设置过cell可复用的情况

1
2
3
4
5
var cell: UITableViewCell! = tableView.dequeueReusableCell(withIdentifier: "Cell")

if cell == nil {
cell = UITableViewCell(style: .default, reuseIdentifier: "Cell")
}

一般我们使用上面的代码,先查看storyBoard中是否设置过cell可复用的情况,则会使用cell = UITableViewCell(style: .default, reuseIdentifier: "Cell"),但会带来一个问题,每次tableView都会新建一个cell,而不是复用,这对于资源的消耗是很大的。
解决办法:

1
2
3
4
5
// 在ViewDidLoad()中注册可复用的cell
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")

// 在cellForRowAt中使用该可复用的cell
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

这样即使你在storyBoard中没有设置过,也可以高效率的使用了。

可以实现的方法

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { }

1
2
3
4
5
6
7
8
9
10
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// 1: try loading the "Detail" view controller and typecasting it to be DetailViewController
if let vc = storyboard?.instantiateViewController(withIdentifier: "Detail") as? DetailViewController {
// 2: success! Set its selectedImage property
vc.selectedImage = pictures[indexPath.row]

// 3: now push it onto the navigation controller
navigationController?.pushViewController(vc, animated: true)
}
}

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { }

可以划动删除/添加的操作。(在模拟器上没有成功,但在真机上向左划动会出现”删除”字样。)

1
2
3
4
5
6
7
8
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
objects.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade)
} else if editingStyle == .insert {
// Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
}
}

tableView.layoutMargins / tableView.separatorInset / UIEdgeInsets – 修改边缘的空白空间的大小

未设置前:
withoutUIEdgeInsets.png

在viewDidLoad()中加入代码:

1
2
3
4
// tableView.layoutMargins -- the default spacing to use when laying out content in the view.
tableView.layoutMargins = UIEdgeInsets.zero
// tableView.separatorInset -- the default inset of cell separators.
tableView.separatorInset = UIEdgeInsets.zero

在tableView的cellForRowAt方法中加入代码:

1
2
// 这里是给每个cell设置layoutMargins
cell.layoutMargins = UIEdgeInsets.zero

设置后的效果:
WithUIEdgeInsets

tableView.backgroundColor – 设置tableView的底色

将某张图片设置tableView的底色,并编排该底色

1
2
3
if let backgroundImage = UIImage(named: "white_wall") {
tableView.backgroundColor = UIColor(patternImage: backgroundImage)
}

tableView.allowsMultipleSelection – 除了编辑状态下,允许多行选定

1
tableView.allowsMultipleSelection = true

tableView.allowsMultipleSelectionDuringEditing – 编辑状态下允许多行选定

1
tableView.allowsMultipleSelectionDuringEditing = true

tableView.reloadData()

Reloads the rows and sections of the table view:

1
tableView.reloadData()

tableView.reloadRows(at: [IndexPath], with: UITableView.RowAnimation)

Reloads the specified rows using the provided animation effect.

1
tableView.reloadRows(at: [indexPath], with: .none)

override func setEditing(_ editing: Bool, animated: Bool) {} – 默认

这是个默认在tableView中会实现的方法,一般不需要去修改它。
比如你定义了一个UIBarButtonItem:

1
editButton = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(enterEditingMode))

若点击该edit的button,就会自动调用到setEditing方法。
setEditing(true, animated: true) – 就是出现editing模式;
setEditing(false, animated: true) – 就是取消editing模式。

那么,什么时候需要覆写该setEditing方法呢?
比如当你想点击该edit按钮后,你想让toolbarItems出现你自定义的按钮,包括你想取消editing模式时,又出现toolbarItems自定义的其他按钮,那你就可以去使用它,但每次覆写都必须实现super.setEditing(editing, animated: animated):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 点击edit按钮调用的方法:
@objc func enterEditingMode() {
navigationItem.rightBarButtonItems = [cancelButton]
// toolbarItems = [spacerButton, deleteAllButton]
setEditing(true, animated: true)
}

// 取消edit模式调用的方法:
@objc func cancelEditingMode() {
navigationItem.rightBarButtonItems = [editButtonItem]
// toolbarItems = [spacerButton, notesCountButton, spacerButton, newNoteButton]
setEditing(false, animated: true)
}

override func setEditing(_ editing: Bool, animated: Bool) { // ??
super.setEditing(editing, animated: animated)

if editing {
toolbarItems = [spacerButton]
} else {
toolbarItems = [spacerButton, notesCountButton, spacerButton, newNoteButton]
}
}

tableView.isEditing – 反映tableView是否在editing模式

tableView.isEditing is a boolean value that determines whether the table view is in editing mode.
例子请结合 tableView.indexPathsForSelectedRows 一起看。

tableView.indexPathsForSelectedRows

The index paths that represent the selected rows. 就是一个所有被选中cell的index列表。

1
2
3
4
5
6
7
8
override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
if tableView.isEditing {
if tableView.indexPathsForSelectedRows == nil || tableView.indexPathsForSelectedRows!.isEmpty {
toolbarItems = [spacerButton, deleteAllButton]
}
// 其他代码省略
}
}

tableView.indexPathForSelectedRow – 返回被选中列的IndexPath

cell.selectedBackgroundView – 设置cell在选中状态下的背景view

1
2
3
// cell: UITableViewCell
// selectedCellView: UIView?
cell.selectedBackgroundView = selectedCellView

cell.multipleSelectionBackgroundView – 设置cell在被多选状态下的背景view

1
2
3
4
// cell: UITableViewCell
let multipleSelectedCellView = UIView()
multipleSelectedCellView.backgroundColor = UIColor.orange.withAlphaComponent(0.2)
cell.multipleSelectionBackgroundView = multipleSelectedCellView

UITableViewCell.tintColor – cell在editing模式下选择区域的背景颜色

1
2
// cell: UITableViewCell
cell.tintColor = .orange

cell-tintColor

tableView.rowHeight – 设置tableView的每个cell的高度

tableView.rowHeight = 90

tableView.separatorStyle – 设置每个cell的分隔类型

tableView.separatorStyle = .singleLine
tableView.separatorStyle = .none

UITableViewDataSource – protocol

具体实例参考:
github上clarknt写的关于100daysOfswift的Milestone-Project28-30的实例代码。
从代码实例中的一些思考:
主页面push出一个setting页面,setting页面内有两个tableView,所以在setting页面需要识别tableView是哪一个,而两个tableView分别有1个section和26个section,所以在UITableViewDataSource协议的实现方法中要手动去识别区分是哪个table。
学习到的是,以前写UITableView的时候,从来没有去实现过UITableViewDataSource协议。

必须要实现的方法: numberOfRowsInSection cellForRowAt

可选择实现的方法1:
numberOfSections – 确定有多少个分区(section)
例如:

1
2
3
4
5
6
7
func numberOfSections(in tableView: UITableView) -> Int {
if tableView == cardsTable {
return 1
}

return grids.count
}

可选择实现的方法2:
titleForHeaderInSection – 每个section的标题
例如:

1
2
3
4
5
6
7
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if tableView == cardsTable {
return ""
}

return "Cards: \(grids[section].numberOfElements)"
}

可选择实现的方法3:
titleForFooterInSection – 每个section的脚标

可选择实现的方法4:
canMoveRowAtIndexPath – 用来控制cell是否可以移动,只有实现了才行移动。

numberOfSections

1
2
3
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}

UIImage

生成UIImage的方法

UIImage(named:)

1
2
let imageName = "nssl0042.jpg"
let image = UIImage(named: imageName)

UIImage(named:)和UIImage(contentsOfFile:)的区别

UIImage(named:)可以不写明图片文件的具体路径,UIImage(contentsOfFile:)必须要写明图片文件的具体路径,这个比较麻烦,但我们可以这样:

1
2
let path = Bundle.main.path(forResource: imageName, ofType: nil)!
let original = UIImage(contentsOfFile: path)!

此外,最重要的一个区别,也是会影响到app性能的一个区别就是:
UIImage(named:)加载完图片后会加入缓存,而UIImage(contentsOfFile:)并不会加入缓存。那么前者加载图片会比较快,而后者会比较慢,但前者会占据大量缓存,加载大量图片后,可能会让缓存吃紧,而后者就不会出现这种情况。

UINavigationController

Declaration:

1
@MainActor class UINavigationController : UIViewController

title - 标题

直接在页面使用 title = “ I’m the title, etc. “ 即可。

largeTitle

在UINavigationController中,title的default style都是small text,那么如何设置成largeTitle呢?

1
navigationController?.navigationBar.prefersLargeTitles = true

以上。

那么,一些类似detail的页面不需要largeTitle而主页面需要的情况下,该怎么设置呢?
只要在detail页面加入代码:

1
navigationItem.largeTitleDisplayMode = .never

以上。

UINavigationBar

By default, a UINavigationController has a bar across the top, called a UINavigationBar, and as developers we can add buttons to this navigation bar that call our methods.

1
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareTapped))

这里选择的是barButtonSystemItem: .action, 可以选择其他的,比如 .cancel / .done / .add / .camera /.bookmarks /.edit 等等很多其他的。

还可以让图标自定义显示:

1
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Score", style: .plain, target: self, action: #selector(showScore))

该自定义的图标可设置样式:

1
navigationItem.leftBarButtonItem.setTitleTextAttributes([NSAttributedString.Key.font: .systemFont(ofSize: 11), NSAttributedString.Key.foregroundColor : UIColor.darkText], for: .normal)

点击rightBarButton出现跳转页面后,才会出现的BarButton。

1
2
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addWhistle))
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Home", style: .plain, target: nil, action: nil)

点击页面即隐藏或显示navigationBar

1
2
3
4
5
6
7
8
9
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.hidesBarsOnTap = true
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.hidesBarsOnTap = false
}

在显示该页面时,即开启点击页面任何处可隐藏navigationBar,再点击可显示navigationBar的功能。
但为何在viewWillDisappear中还要取消该功能?
想想这样的场景,在detail页面可观看一张图片,点击可显示/隐藏navigationBar,但如果你点击返回退回到前一页(可能是主页面),该页面并不需要隐藏navigationBar,但却在navigationController中开启了该功能,会非常的不好,所以需要取消。

UIActivityIndicatorView – 呈现loading转圈的状态

1
2
3
4
let spinner = UIActivityIndicatorView(style: .large)
spinner.startAnimating()

navigationItem.leftBarButtonItem = UIBarButtonItem(customView: spinner)

UIActivityIndicatorView

Disclosure Indicator

Disclosure Indicator 即如下图每个cell右边的箭头(>)符号:
disclosureIndicator
如何设置显示或不显示:
在storyBoard中选中cell,在其的属性中的Accessory中去设置:
accesoryOfCell
可以看到我们选择的是DisclosureIndicator,还有DetailDisclosure/Checkmark/Detail都是很好的选择,主要看所使用的场景。

UINavigationController? 的 popViewController / pushViewController

UINavigationController是IOS编程中的一个view controller的容器,通过栈管理viewControllers,每一次pushViewController操作都将在栈顶添加一个view controller,然后通过popViewController将该栈最顶端的controller移除。
上面说的栈最顶端是这样的,navigationController?.viewControllers中保存着所有的view controller,你push进去一个view controller,就放在这个列表的最后面,你pop的话,也是将列表的最后面那个view controller删除。这就是栈的后进先出的概念。

这里自己写了个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ViewController.swift
class ViewController: UIViewController {
@IBOutlet var textView: UITextView!


override func viewDidLoad() {
super.viewDidLoad()

navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(toDetailView))

print(navigationController?.viewControllers)
// Optional([<SwiftTest001.ViewController: 0x117e07490>])
// 一个ViewController

navigationController?.popViewController(animated: true)
// 这里pop了,为什么下面显示的栈里面还是有一个ViewController?
// 感觉是因为栈里总得有一个ViewController吧,
// 所以这行代码是没有意义的。

print(navigationController?.viewControllers)
// Optional([<SwiftTest001.ViewController: 0x117e07490>])
// 还是原来那个ViewController
}

@objc func toDetailView() {
if let vc = storyboard?.instantiateViewController(withIdentifier: "Detail") as? DetailViewController {
navigationController?.pushViewController(vc, animated: true)
}
}
}

// DetailViewController.swift
class DetailViewController: UIViewController {

override func viewDidLoad() {
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(deleteTopView))

print(navigationController?.viewControllers)
// Optional([<SwiftTest001.ViewController: 0x117e07490>, <SwiftTest001.DetailViewController: 0x117e22d20>])
// 有二个ViewController了,栈顶就是这个DetailViewController
}

@objc func deleteTopView() {
navigationController?.popViewController(animated: true)

print(navigationController?.viewControllers)
// Optional([<SwiftTest001.ViewController: 0x117e07490>])
// 又回到原来的那个ViewController了,pop出的就是后进来的DetailViewController了。
}
}

UIButton

设置UIButton上的文字 – .setTitle()

1
2
3
4
5
let submit = UIButton(type: .system)
submit.translatesAutoresizingMaskIntoConstraints = false
// 设置显示的文字
submit.setTitle("SUBMIT", for: .normal)
view.addSubview(submit)

在UIButton上设置UIImage

1
2
// 前提是,button1是UIButton类型的按钮
button1.setImage(UIImage(named: countries[0]), for: .normal)

设置UIButton的边框粗细

1
button1.layer.borderWidth = 1

our border will be 1 pixel on non-retina devices, 2 pixels on retina devices, and 3 on retina HD devices.

设置UIButton的边框颜色

1
button1.layer.borderColor = UIColor.lightGray.cgColor

By default, the border of CALayer is black, but you can change that if you want by using the UIColor data type. I said that CALayer brings with it a little more complexity, and here’s where it starts to be visible: CALayer sits at a lower technical level than UIButton, which means it doesn’t understand what a UIColor is. UIButton knows what a UIColor is because they are both at the same technical level, but CALayer is below UIButton, so UIColor is a mystery.

Don’t despair, though: CALayer has its own way of setting colors called CGColor, which comes from Apple’s Core Graphics framework. This, like CALayer, is at a lower level than UIButton, so the two can talk happily – again, as long as you’re happy with the extra complexity.

Even better, UIColor (which sits above CGColor) is able to convert to and from CGColor easily, which means you don’t need to worry about the complexity.

以上的颜色也可以写成这种表现形式:

1
UIColor(red: 1.0, green: 0.6, blue: 0.2, alpha: 1.0).cgColor

设置UIButton的大小

1
2
3
4
let letterButton = UIButton(type: .system)
letterButton.setTitle("WWW", for: .normal)
let frame = CGRect(x: col * width, y: row * height, width: width, height: height)
letterButton.frame = frame

通过IB来创建UIButton的Action方法

https://www.hackingwithswift.com/read/2/5/from-outlets-to-actions-creating-an-ibaction

在IB中通过ctrl拉动UIButton按钮至assistant页面中,跳出页面选项选择Connection:Action而非Connection:Outlet。

会生成按钮点击的@IBAction方法:

1
2
@IBAction func buttonTapped(_ sender: UIButton) {
}

通过此方法,可以使多个按钮使用同一方法的情况。
注意:有可能上面的sender的属性是Any,这时候最好把Any改成UIButton。

如何在多个按钮使用同一方法的情况下,识别是哪个按钮被点击?

在IB界面的这个UIButton属性中设置其的tag为一个唯一的数字:
tagOfUIButton

随后在这个UIButton按钮的action中可查看其的tag:

1
2
3
@IBAction func buttonTapped(_ sender: UIButton) {
print(sender.tag)
}

sender.isHidden 的Bool值 让UIButton隐身

在 @IBAction func buttonTapped(_ sender: UIButton) { 中
设置sender.isHidden = true 或者 false 可让该UIButton隐藏或显示。

通过代码来给UIButton()添加action

1
2
3
4
5
6
7
8
9
10
let submit = UIButton(type: .system)
submit.translatesAutoresizingMaskIntoConstraints = false
submit.setTitle("SUBMIT", for: .normal)
view.addSubview(submit)

// 为submit按钮添加一个objc类型的submitTapped方法,而且激活按钮要求是.touchUpInside,即按钮按下并松开时,松开手势得在按钮正上方
submit.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)

@objc func submitTapped(_ sender: UIButton) { }

让UIButton()隐藏但仍占据位置

1
2
3
4
5
6
7
let submit = UIButton(type: .system)
submit.translatesAutoresizingMaskIntoConstraints = false
submit.setTitle("SUBMIT", for: .normal)
view.addSubview(submit)

// 隐藏该submit按钮但仍旧占位
submit.isHidden = true

UIAlertController

一般使用

1
2
3
4
5
6
7
8
let ac = UIAlertController(title: title, message: "Your score is \(score).", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "Continue", style: .default, handler: askQuestion))
present(ac, animated: true)

// 对应的askQuestion方法
// 既设置了必要的action参数,也可在调用时不输入该参数。
func askQuestion(action: UIAlertAction! = nil) { }

Apple recommends you use .alert when telling users about a situation change, and .actionSheet when asking them to choose from a set of options.

像下面这样的方式 (preferredStyle: .actionSheet)

popoverPresentationController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Open", style: .plain, target: self, action: #selector(openTapped))


@objc func openTapped() {
let ac = UIAlertController(title: "Open page…", message: nil, preferredStyle: .actionSheet)

// var websites = ["apple.com", "hackingwithswift.com"]
for website in websites {
ac.addAction(UIAlertAction(title: website, style: .default, handler: openPage))
}

ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
// 感觉主要是下面的代码,让菜单从下往上弹的。
// 没感觉下面这行代码的意义,没有也一样可以,
// 但看到的很多代码都有这一样,而且一般是哪个button点击后调用的,
// 就设置ac.popoverPresentationController?.barButtonItem对应该button。
// 但为啥要这么设置啊??????????
ac.popoverPresentationController?.barButtonItem = self.navigationItem.rightBarButtonItem
present(ac, animated: true)
}

添加TextField文本框供输入

1
2
3
4
5
6
7
8
9
10
11
12
13
@objc func promptForAnswer() {
let ac = UIAlertController(title: "Enter answer", message: nil, preferredStyle: .alert)
// 可以添加不止一个TextField输入框
ac.addTextField()

let submitAction = UIAlertAction(title: "Submit", style: .default) { [weak self, weak ac] action in
guard let answer = ac?.textFields?[0].text else { return }
self?.submit(answer)
}

ac.addAction(submitAction)
present(ac, animated: true)
}

ac.addTextField(configurationHandler: <#T##((UITextField) -> Void)?##((UITextField) -> Void)?##(UITextField) -> Void#>)

下面是当注册需要设置密码时的例子:

1
2
3
4
ac.addTextField { textField in
textField.isSecureTextEntry = true
textField.placeholder = "Password"
}

textFieldWithPassword

UIActivityViewController

UIActivityViewController will automatically give us functionality to share by iMessage, by email and by Twitter and Facebook, as well as saving the image to the photo library, assigning it to contact, printing it out via AirPrint, and more. It even hooks into AirDrop and the iOS extensions system so that other apps can read the image straight from us.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareTapped))

@objc func shareTapped() {
guard let image = imageView.image?.jpegData(compressionQuality: 0.8) else {
print("No image found")
return
}

// "activityItems:"是要传递的东西。这里传递的是一张图片。
let vc = UIActivityViewController(activityItems: [imageView.image!], applicationActivities: [])
// 这里将vc.popoverPresentationController?.barButtonItem也绑定在navigationItem.rightBarButtonItem,
// 感觉是,总得绑在一个触发的UIBarButtonItem上面,而只有这个正在被触发。
vc.popoverPresentationController?.barButtonItem = navigationItem.rightBarButtonItem
present(vc, animated: true)
}

若选择Save Image,还需要编辑Info.plist

在Info.plist中添加row,选择 “Privacy - Photo Library Additions Usage Description”, 添加String值类似“We need to save photos you like.”

UIToolbar

UIToolbar holds and shows a collection of UIBarButtonItem objects that the user can tap on.

toolbarItems.png

1
2
3
4
5
6
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let refresh = UIBarButtonItem(barButtonSystemItem: .refresh, target: webView, action: #selector(webView.reload))

toolbarItems = [spacer, refresh]
// 决定toolbarItems是否显示
navigationController?.isToolbarHidden = false

toolbar的样式设定

1
2
3
4
5
6
7
8
9
guard let toolbar = navigationController?.toolbar else { return }

// background: transparent
toolbar.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: .default)
toolbar.setShadowImage(UIImage(), forToolbarPosition: .any)
toolbar.isTranslucent = true

// foreground
toolbar.tintColor = .orange

WKWebView

import

1
import WebKit

正常使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ViewController: UIViewController, WKNavigationDelegate {
var webView: WKWebView!

// 为什么要在loadView()而非viewDidLoad()中加载webView,那是因为这个view需要直接load成功,而非load完后再去加载,感觉会造成资源浪费
override func loadView() {
webView = WKWebView()
// 因为下面这行,所以ViewController需要遵循WKNavigationDelegate !!!
webView.navigationDelegate = self
view = webView
}

override func viewDidLoad() {
let url = URL(string: "https://www.hackingwithswift.com")!
webView.load(URLRequest(url: url))
webView.allowsBackForwardNavigationGestures = true
}
}

使用webView加载内容,并显示出来

显示效果是类似这样的:
webViewLoadHtmlString

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import UIKit
import WebKit

struct Petition: Codable {
var title: String
var body: String
var signatureCount: Int
}

class DetailViewController: UIViewController {
var webView: WKWebView!
var detailItem: Petition?

override func loadView() {
webView = WKWebView()
view = webView
}

override func viewDidLoad() {
super.viewDidLoad()

guard let detailItem = detailItem else { return }

let html = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style> body { font-size: 150%; } </style>
</head>
<body>
\(detailItem.body)
</body>
</html>
"""

webView.loadHTMLString(html, baseURL: nil)
}
}

webView可实现的方法

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { }

1
2
3
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
title = webView.title
}

可实现网页显示标题

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url

if let host = url?.host {
for website in websites {
if host.contains(website) {
decisionHandler(.allow)
return
}
}
}

decisionHandler(.cancel)
}

这方法就像是网页浏览器的过滤器,任何url都会在这边过滤一下,最终决定是decisionHandler(.allow)运行呢,还是decisionHandler(.cancel)拒绝呢。

UIProgressView

UIProgressView is a colored bar that shows how far a task is through its work, sometimes called a “progress bar.”

实际案例 + 使用到addObserver/observeValue这两个观察者/处理者(即KVO)

通过网页的加载进度,直接反映在UIProgressView进度条上:

https://www.hackingwithswift.com/read/4/4/monitoring-page-loads-uitoolbar-and-uiprogressview

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var progressView: UIProgressView!

override func loadView() {
webView = WKWebView()
webView.navigationDelegate = self
view = webView
}

override func viewDidLoad() {
// 省略其他代码

let url = URL(string: "https://hackingwithswift.com")!
webView.load(URLRequest(url: url))

progressView = UIProgressView(progressViewStyle: .default)
progressView.sizeToFit()
let progressButton = UIBarButtonItem(customView: progressView)

// 这里就让progressButton占满toolbarItems这一整行
toolbarItems = [progressButton]
// 决定toolbarItems是否显示
navigationController?.isToolbarHidden = false

// 在这个被定位主view的webView中增加一个观察者,主要观察这个key--WKWebView.estimatedProgress,有变化就会被告知
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil)
}

// 这是观察后实时进行处理的函数
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "estimatedProgress" {
// estimatedProgress is a Double, Unhelpfully, UIProgressView's progress property is a Float
progressView.progress = Float(webView.estimatedProgress)
}
}


UITextChecker

UITextChecker类来源于UIKit,在SwiftUI没有替代方案的情况下,只能使用该方法了。

使用的案例(简单实现,下面的NSRange和rangeOfMisspelledWord又会重复一遍这个操作):
查找用户输入的英语单词是否在字典里,有没有拼错的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import UIKit

let word = "swift"

let checker = UITextChecker()

// location是从哪个位置开始,一般都是0,length是计算word在utf16中的长度
let range = NSRange(location: 0, length: word.utf16.count)

// in是要核对的词,range是核对的范围长度,wrap是持续核对的意思(具体没有试过)
let misspelledRange = checker.rangeOfMisspelledWord(in: word, range: range, startingAt: 0, wrap: false, language: "en")

// 返回值要么是The range of the first misspelled word encountered
// 没找到就是{NSNotFound, 0} if none is found.
let allGood = misspelledRange.location == NSNotFound
// 有这个单词也没拼错的情况返回true,不然返回false

方法的解释可以参考下面的文章:

https://www.hackingwithswift.com/books/ios-swiftui/working-with-strings
https://www.hackingwithswift.com/books/ios-swiftui/validating-words-with-uitextchecker

注意:
UITextChecker uses the built-in system dictionary.我们不用为其特意准备单词表等文档。

Auto Layout 自动页面布局的设置

通过Storyboard来设置

https://www.hackingwithswift.com/read/6/2/advanced-auto-layout

以上是三面国旗间如何通过storyboard来自动设置布局,以让起在portrait/landscape中均能够有一个好的效果。
通过该视频就能大致了解了,而看文字的话比较繁琐。

通过Storyboard自动布局后如何让它变为自动布局

在Storyboard中布局各个view后,可以让屏幕自动布局页面,这样在landscape或是不同屏幕大小的机型上,都可以自动适应布局:

  1. Select the view controller by clicking on “View Controller” in the document outline,
  2. then go to the Editor menu
  3. and choose Resolve Auto Layout Issues > Reset To Suggested Constraints.

通过Storyboard布局后,如何让一个view在Storyboard上回到它应该显示的位置上?

我们在Storyboard上扔下一个UILabel,让其显示”Here is a UILbale !”,字体设置为30,随后通过鼠标contrl指向空白处,此时选择”Center Horizontally in safe area” / “Center Vertically in safe area”,随后鼠标点击该label,我们看的是:
labelLayout
我们来分析一个这个图:
可以看到实线包围的label和一个虚线包围的label。实线的(the solid orange lines)代表你的label现在在的位置,而虚线的(the dashed orange lines)代表程序运行后你的label会在的位置。

那么如何让这个label回到它在程序运行后应该在的位置呢?
Editor menu and choosing Resolve Auto Layout Issues > Update Frames
设置后是这样的:
labelLayout
这样就这样了,没有橙线了。

通过addConstraints with Visual Format Language (VFL)

https://www.hackingwithswift.com/read/6/3/auto-layout-in-code-addconstraints-with-visual-format-language

一个不通过Storyboard可视化布局来显示页面的简单例子:
(因为之前有执念一直在想有没有办法能够实现,所以一知道怎么做了,就写进了笔记里)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override func viewDidLoad() {
super.viewDidLoad()

let imageView = UIImageView()
// 关键的translatesAutoresizingMaskIntoConstraints的设置
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = UIImage(systemName: "star")
imageView.layer.backgroundColor = UIColor.red.cgColor
// 这里不要关心我这里布局代码用到的是anchor,只是随便用了个布局方法
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true

view.addSubview((imageView))

}

这里之前一直想随便写个view让其能够显示,始终没有显示,后来发觉是因为没有这行代码:

1
imageView.translatesAutoresizingMaskIntoConstraints = false

because by default iOS generates Auto Layout constraints for you based on a view’s size and position. We’ll be doing it by hand, so we need to disable this feature.

Q: 为什么 imageView.translatesAutoresizingMaskIntoConstraints = true 的时候就没有显示这个view呢?
A: I have no answer…….

详细一点的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 首先是 五个label图像元素
verride func viewDidLoad() {
super.viewDidLoad()

let label1 = UILabel()
label1.translatesAutoresizingMaskIntoConstraints = false
label1.backgroundColor = UIColor.red
label1.text = "THESE"
label1.sizeToFit()

let label2 = UILabel()
label2.translatesAutoresizingMaskIntoConstraints = false
label2.backgroundColor = UIColor.cyan
label2.text = "ARE"
label2.sizeToFit()

let label3 = UILabel()
label3.translatesAutoresizingMaskIntoConstraints = false
label3.backgroundColor = UIColor.yellow
label3.text = "SOME"
label3.sizeToFit()

let label4 = UILabel()
label4.translatesAutoresizingMaskIntoConstraints = false
label4.backgroundColor = UIColor.green
label4.text = "AWESOME"
label4.sizeToFit()

let label5 = UILabel()
label5.translatesAutoresizingMaskIntoConstraints = false
label5.backgroundColor = UIColor.orange
label5.text = "LABELS"
label5.sizeToFit()

view.addSubview(label1)
view.addSubview(label2)
view.addSubview(label3)
view.addSubview(label4)
view.addSubview(label5)
}

现在这些图像都挤在左上角!还互相叠着!

设置一个dict:

1
let viewsDictionary = ["label1": label1, "label2": label2, "label3": label3, "label4": label4, "label5": label5]

添加布局:

1
2
3
for label in viewsDictionary.keys {
view.addConstraints( NSLayoutConstraint.constraints(withVisualFormat: "H:|[\(label)]|", options: [], metrics: nil, views: viewsDictionary))
}

NSLayoutConstraint.constraints(withVisualFormat:)相对比较关键。
里面的用字符串表示的”H:|[label1]|”中,H代表Horizontal,前后的两个|分别代表了屏幕的左右边缘。

看懂下面的代码:

1
view.addConstraints( NSLayoutConstraint.constraints(withVisualFormat: "V:|[label1]-[label2]-[label3]-[label4]-[label5]", options: [], metrics: nil, views: viewsDictionary))

5个label按照次序沿着屏幕左侧开始依次排列。其中分隔每个label的符号”-“意味着有间隔,这个间隔的默认值是10,可以自定义。

以及设置更精确的数据:

1
view.addConstraints( NSLayoutConstraint.constraints(withVisualFormat: "V:|[label1(==88)]-[label2(==88)]-[label3(==88)]-[label4(==88)]-[label5(==88)]-(>=10)-|", options: [], metrics: nil, views: viewsDictionary))

以及使用到metrics参数的情况:

1
2
let metrics = ["labelHeight": 88]
view.addConstraints( NSLayoutConstraint.constraints(withVisualFormat: "V:|[label1(labelHeight)]-[label2(labelHeight)]-[label3(labelHeight)]-[label4(labelHeight)]-[label5(labelHeight)]->=10-|", options: [], metrics: metrics, views: viewsDictionary))

So when your designer / manager / inner-pedant decides that 88 points is wrong and you want some other number, you can change it in one place to have everything update.

以及使用优先级priority的情况:(优先级是从1-1000,数字越大优先级越高) - @数字

1
"V:|[label1(labelHeight@999)]-[label2(label1)]-[label3(label1)]-[label4(label1)]-[label5(label1)]->=10-|"

自己理解:label1设置了一个高度,并对该高度设置了一个优先级,那么其他四个label的高度和其一样(包括优先级)。

通过Auto Layout anchors来自动布局

Every UIView has a set of anchors that define its layouts rules.
The most important ones are
widthAnchor, heightAnchor, topAnchor, bottomAnchor, leftAnchor, rightAnchor, leadingAnchor, trailingAnchor, centerXAnchor, and centerYAnchor.

一般用例:

1
2
3
4
for label in [label1, label2, label3, label4, label5] {
label.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
label.heightAnchor.constraint(equalToConstant: 88).isActive = true
}

一般用例:

1
2
3
4
5
6
7
if let previous = previous {
// we have a previous label – create a height constraint
label.topAnchor.constraint(equalTo: previous.bottomAnchor, constant: 10).isActive = true
} else {
// this is the first label
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0).isActive = true
}

这里用到的view.safeAreaLayoutGuide其实理解下来就是除了上下那两块的其他安全区域的屏幕范围。

还有这样的:

1
yourView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor, multiplier: 0.5, constant: 50).isActive = true

就是在0.5倍的基础上再加50的意思。

还可以用到NSLayoutConstraint.activate集合很多规则:

1
2
3
4
5
6
NSLayoutConstraint.activate([
scoreLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
scoreLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),

// more constraints to be added here!
])

Notice the way I’m pinning the label to view.layoutMarginsGuide – that will make the score label have a little distance from the right edge of the screen.

yourView.setContentHuggingPriority(UILayoutPriority(Int), for: )

1
2
cluesLabel.setContentHuggingPriority(UILayoutPriority(1), for: .vertical)
answersLabel.setContentHuggingPriority(UILayoutPriority(1), for: .vertical)

优先级是从1-1000的,此处设置为1,代表最没有优先级,for是指适用于垂直面的。

创建的Game项目的屏幕大小layout的适配

https://www.hackingwithswift.com/read/14/2/getting-up-and-running-skcropnode

创建了一个Game项目,之前教程中均是使用spriteKit来制作的,一般都会设置屏幕大小为1024*768,这在一般iPad上都没问题,11-inch iPad Pro会比较特殊,所以需要做一个适配:
在GameViewController.swift中,将
“scene.scaleMode = .aspectFill”
替换成
“scene.scaleMode = .fill”

UIEdgeInsets – 类似于给UIView加一个padding的设置

我们创建一个UITextView:

1
@IBOutlet var textView: UITextView!

并在storyboard上设置了它的大小,执行后是这样的:
UIEdgeInsets

如果我们给它加上Inset:

1
textView.contentInset = UIEdgeInsets(top: 50, left: 50, bottom: 50, right: 50)

它就会是这样:
UIEdgeInsets

UITabBarController

如何更改Tabbar的显示文字和图标

假设3个页面依次嵌套是这样的:
View Controller Scene – Navigation Controller Scene – Tab Bar Controller Scene

如何更改Tabbar的显示文字和图标?
需要在Storyboard中找到Navigation Controller Scene(而非Tab Bar Controller Scene),随后选到这个Tabbar元素(a new type of object called a UITabBarItem),再右侧属性栏的Bar Item中去更改其的Title和Image。

如何增加Tab页面

https://www.hackingwithswift.com/read/7/5/finishing-touches-didfinishlaunchingwithoptions

打开AppDelegate.swif,找寻到didFinishLaunchingWithOptions方法,在里面添加如下代码,最终的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
if let tabBarController = window?.rootViewController as? UITabBarController {
// 以上代码,因为上面讲过3个页面嵌套,Tab Bar Controller Scene是最root的view
// 找到我们的storyboard,我们的storyboard默认取名是Main
let storyboard = UIStoryboard(name: "Main", bundle: nil)
// 之前给Navigation Controller Scene起过一个StoryboardID是NavController,这里生成一个这样的页面
let vc = storyboard.instantiateViewController(withIdentifier: "NavController")
// 从storyboard中可以看到,Navigation Controller Scene里面有一个tabBarItem,
// 所以给tabBarItem定义成一个UITabBarItem,重要的是tag为1,因为本来就有存在的那个tag为0
vc.tabBarItem = UITabBarItem(tabBarSystemItem: .topRated, tag: 1)
// 将该tabBarItem所在的vc添加进tabBarController
tabBarController.viewControllers?.append(vc)
}

return true
}

随后,如何在相同的一个ViewControllerScene中区别开是哪个tab页面?

1
2
3
4
5
6
7
8
9
let urlString: String

if navigationController?.tabBarItem.tag == 0 {
// urlString = "https://api.whitehouse.gov/v1/petitions.json?limit=100"
urlString = "https://www.hackingwithswift.com/samples/petitions-1.json"
} else {
// urlString = "https://api.whitehouse.gov/v1/petitions.json?signatureCountFloor=10000&limit=100"
urlString = "https://www.hackingwithswift.com/samples/petitions-2.json"
}

UIFont

根据用户在手机设置中的字体大小来自动调节

1
2
var font = UIFont.preferredFont(forTextStyle: .body).withSize(fontSize)
font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)

UILabel()

1
2
3
4
5
6
7
8
9
10
11
12
13
scoreLabel = UILabel()
scoreLabel.translatesAutoresizingMaskIntoConstraints = false
// 设置对齐
scoreLabel.textAlignment = .right
// 设置内容
scoreLabel.text = "Score: 0"
// 设置字体大小
scoreLabel.font = UIFont.systemFont(ofSize: 24)
// 设置背景色
scoreLabel.backgroundColor = .blue
// 设置字体颜色
scoreLabel.textColor = UIColor.red
view.addSubview(scoreLabel)

UITextfield

1
2
3
4
5
6
7
8
9
10
11
currentAnswer = UITextField()
currentAnswer.translatesAutoresizingMaskIntoConstraints = false
// 设置placeholder
currentAnswer.placeholder = "Tap letters to guess"
// 设置对齐
currentAnswer.textAlignment = .center
// 设置字体大小
currentAnswer.font = UIFont.systemFont(ofSize: 44)
// 设置是否用户可输入
currentAnswer.isUserInteractionEnabled = false
view.addSubview(currentAnswer)

注意:UITextField只能显示一行,而UITextView可以显示多行,这就是两者的区别。

UITextView

UITextView可以显示多行,而UITextField只能显示一行,这就是两者的区别。

UITextView.contentInset – 类似于一个padding

具体操作见 “Auto Layout 自动页面布局的设置” -> “UIEdgeInsets” 。

UITextView.endEditing(true/false) – 隐藏/显示 keyboard

1
2
// textView: UITextView
textView.endEditing(true)

UITextView的resignFirstResponder()

就是取消focus,让键盘隐藏的作用。
This is used to tell a view that has input focus that it should give up that focus. Or, in Plain English, to tell our text view that we’re finished editing it, so the keyboard can be hidden.

1
2
3
4
5
6
7
8
@objc func saveSecretMessage() {
guard secret.isHidden == false else { return }

KeychainWrapper.standard.set(secret.text, forKey: "SecretMessage")
secret.resignFirstResponder()
secret.isHidden = true
title = "Nothing to see here"
}

UITextView的delegate

UITextView的delegate需要遵循UITextViewDelegate协议。

这样当用户在UITextView中开始输入时,就会向
func textViewDidBeginEditing(_ textView: UITextView) {}方法
传输信息。

GCD - Grand Central Dispatch

GCD是帮助你自动化管理进程的一套东西。GCD中的三个方法之一,最重要的就是async()。

后台进程中,有四种选择,或者叫QoS level set:

  1. User Interactive: this is the highest priority background thread, and should be used when you want a background thread to do work that is important to keep your user interface working. This priority will ask the system to dedicate nearly all available CPU time to you to get the job done as quickly as possible.
  2. User Initiated: this should be used to execute tasks requested by the user that they are now waiting for in order to continue using your app. It’s not as important as user interactive work – i.e., if the user taps on buttons to do other stuff, that should be executed first – but it is important because you’re keeping the user waiting.
  3. The Utility queue: this should be used for long-running tasks that the user is aware of, but not necessarily desperate for now. If the user has requested something and can happily leave it running while they do something else with your app, you should use Utility.
  4. The Background queue: this is for long-running tasks that the user isn’t actively aware of, or at least doesn’t care about its progress or when it completes.

Those QoS queues affect the way the system prioritizes your work: User Interactive and User Initiated tasks will be executed as quickly as possible regardless of their effect on battery life, Utility tasks will be executed with a view to keeping power efficiency as high as possible without sacrificing too much performance, whereas Background tasks will be executed with power efficiency as its priority.

如何使用:

1
2
3
// 在默认的Background queue进程中
// The default GCD background queue has a lower priority than .userInitiated but higher than .utility.
DispatchQueue.global().async {
1
2
// 指定在User Initiated进程中
DispatchQueue.global(qos: .userInitiated).async {

此外,在 async() 里面的代码不需要使用[weak self] in之类的语句,因为async()执行完就会被丢弃,不存在留存东西的情况。

performSelector(inBackground:)performSelector(onMainThread:) 这两种方法更好用一些,因为更加简单,只要决定是放在main thread,还是background上运行就行。
(初步用下来,performSelector会有些问题,莫名的警告之类的,可能更推荐使用DispatchQueue.global()/DispatchQueue.main之类的吧)
示例代码:

1
2
3
4
5
6
7
performSelector(onMainThread: #selector(showError), with: nil, waitUntilDone: false)

@objc func showError() {
let ac = UIAlertController(title: "Loading error", message: "There was a problem loading the feed; please check your connection and try again.", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
present(ac, animated: true)
}

This is an Objective-C call, so the @objc attribute is required.

这里发现个情况,几种代码的情况竟然都是可以的:
场景是,一个class ViewController: UITableViewController内定义了fun tableView的实现,这时候,需要在background中对tabaleView中的UI数据进行更新,肯定是要在主线程即main thread中更新的,
我们可以:

1
2
3
DispatchQueue.main.async {
self?.tableView.reloadData()
}

也可以:

1
2
// 这里写self?是因为在DispatchQueue.global().async这个closure中,里面引用的外部self必须是个optional,这里请忽略
self?.tableView.performSelector(onMainThread: #selector(self?.tableView.reloadData), with: nil, waitUntilDone: false)

竟然还可以:

1
self?.tableView.performSelector(onMainThread: #selector(UITableView.reloadData), with: nil, waitUntilDone: false)

没想到的是,#selector(UITableView.reloadData)中的“UITableView.”竟然也是可以引用到这个方法的,一时半会儿有点概念不清晰,但又感觉可能可以。

DispatchWorkItem – dispatch queue 或 dispatch group 下的运行单元

定义:
A DispatchWorkItem encapsulates work to be performed on a dispatch queue or a dispatch group. It is primarily used in scenarios where we require the capability of delaying or canceling a block of code from executing.

为什么需要DispatchWorkItem,而不是直接在DispatchQueue.global()/DispatchQueue.main中直接定义要运行的代码?
因为DispatchWorkItem可以为后续停止该DispatchWorkItem埋下伏笔。
在开始或运行该DispatchWorkItem之前,你都可以cancel掉或delay掉。

示例代码1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Controller {
var workItem: DispatchWorkItem?
func getSearchResults(query: String) {
workItem?.cancel()
let newWorkItem = DispatchWorkItem {
print("send a backend request for \(query)")
// async task to fetch results based on the query
}
workItem = newWorkItem
DispatchQueue.global().asyncAfter(
deadline: .now() + .milliseconds(30), execute: newWorkItem
)
}
}

let cont = Controller()
cons.getSearchResults(query: "s")
cons.getSearchResults(query: "sh")
Thread.sleep(forTimeInterval: Double.random(in: 1...3))
cons.getSearchResults(query: "shi")
cons.getSearchResults(query: "shir")

// 执行后的打印结果:
// send a backend request for sh
// send a backend request for shir

以上代码的使用场景,比如用户在注册用户名时,当用户输入时,下一个输入字符超过多少时长的情况下,程序就会将该输入的字符放松到服务器进行校验;若在规定时间内输入了,由于有workItem?.cancel(),所以就会取消校验,等下次用户的输入超时的情况。

示例代码2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainViewController: UIViewController {
var showAnimatedPresentation: DispatchWorkItem?

override func viewDidAppear(_ animated: Bool) {
showAnimatedPresentation = DispatchWorkItem {
// show some cool animated presentation
print("showing animated Presentation")
}

// show the animated presentation, if the user hasn't interacted with
// the app for more than 4 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4), execute: showAnimatedPresentation!)
}

func usedTheApp() {
// will cancel the workitem from executing
showAnimatedPresentation?.cancel()
print("app used")
}
}

以上代码,若正常运行,且用户未执行usedTheApp方法,则会在4秒后执行该DispatchWorkItem;但若4秒内用户执行了usedTheApp方法,则该DispatchWorkItem不会被执行到,因为被cancel()掉了。

DispatchWorkItem 的 notify() 和 perform()

DispatchWorkItemNotify() 的作用是:
一个DispatchWorkItem可以指定一个特殊的workItem,使用DispatchWorkItem的Notify()来实现。当DispatchWorkItem执行完毕后,这个特殊的workItem会接着执行,像是一个执行的序列一样:
示例代码3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Controller {
func getSomethingFromServer() {
let newWorkItem = DispatchWorkItem {
print("fetching data from the server")
// async task to fetch results based on the query
}
// work item to notify the view that the info is ready
let notifyTheView = DispatchWorkItem {
print("the info from server has arrived")
}
// this get executed when newWorkItem has finished execution
newWorkItem.notify(queue: .main) {
notifyTheView.perform()
}
DispatchQueue.global().async(execute: newWorkItem)
}
}

let cont = Controller()
cont.getSomethingFromServer()

/*
fetching data from the server
<NSThread: 0x600001ced040>{number = 5, name = (null)}
the info from server has arrived
<_NSMainThread: 0x600001ce81c0>{number = 1, name = main}
*/

newWorkItem执行完的同时,将会执行notify中的内容,而DispatchWorkItemperform()是同步执行的,并非异步执行。

DispatchWorkItem 的 perform()

Perform():Executes the work item’s block synchronously on the current thread.

1
2
3
4
5
let worker = DispatchWorkItem { [weak self] in
// 省略代码
}

worker.perform() // 在当前进程中同步执行

DispatchWorkItem 的 wait()

DispatchWorkItemwait()的作用,是由某个DispatchWorkItem来阻塞(block)这个thread,直到这个DispatchWorkItem执行完毕。
示例代码4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Controller {
func getSomethingFromServer() {
let newWorkItem = DispatchWorkItem {
print("fetching data from the server")
print(Thread.current)
}

DispatchQueue.global().async(execute: newWorkItem)

// blocks the thread until newWorkItem finishes execution
newWorkItem.wait()
// This gets printed after newWorkItem finishes execution
print("finishes execution")
}
}

let cont = Controller()
cont.getSomethingFromServer()

/*
fetching data from the server
<NSThread: 0x600003090f40>{number = 6, name = (null)}
finishes execution
*/

但并不推荐使用wait(),因为会在执行时造成阻塞。

非常非常奇怪:
为何示例代码4代码中,DispatchQueue.global().async(execute: newWorkItem)换成xxxx.main.xxx,在playground中就会停止运行也未报错。
这是为何?

UICollectionView

https://www.hackingwithswift.com/read/10/2/designing-uicollectionview-cells

在Storyboard上创建UIColletionView的过程,与创建UITableView差不多,也是在删除原有的controller,在libraries里拖拉出一个Collection View Controller拖进storyboard。

接下来要建立一个对应UICollectionViewCell的Cocoa Touch Class的文件,在storyboard中找到CollectionViewCell(就如同找到table cell一样),让其的class对应该协议,此外要给该CollectionViewCell的identifier取个名字,好在代码中生成这个cell。

IndexPath(item: Int, section: Int)

let indexPath = IndexPath(item: 1, section: 0)

需要实现的方法

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { }

示例代码:

1
2
3
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 10
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {}

示例代码:

1
2
3
4
5
6
7
8
9
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Person", for: indexPath) as? PersonCell else {
// we failed to get a PersonCell – bail out!
fatalError("Unable to dequeue PersonCell.")
}

// if we're still here it means we got a PersonCell, so we can return it
return cell
}

可以实现的方法

override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { }

示例代码1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let person = people[indexPath.item]

let ac = UIAlertController(title: "Rename person", message: nil, preferredStyle: .alert)
ac.addTextField()

ac.addAction(UIAlertAction(title: "Cancel", style: .cancel))

ac.addAction(UIAlertAction(title: "OK", style: .default) { [weak self, weak ac] _ in
guard let newName = ac?.textFields?[0].text else { return }
person.name = newName

self?.collectionView.reloadData()
})

present(ac, animated: true)
}

示例代码2:

1
2
3
4
5
6
7
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// 取得被点击的cell
guard let cell = collectionView.cellForItem(at: indexPath) as? CardCell else { return }

// ......

}

collectionView.cellForItem(at: IndexPath) – 取得collectionView中指定的cell

具体用法见上面的例子。

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { }

要求处理collectionView的delegate给每个collectionView设置指定的layOut,比如说大小等。

Asks the delegate for the size of the specified item’s cell.

如果你不使用本方法来定义,则collectionView就会使用默认的itemSize,那么如何改变默认尺寸的值呢,应该是在storyBoard中设置Collection Viewcell的尺寸以及Collection View Flow Layout的具体数值。
见图:
CollectionViewFlowLayout
在代码中查看itemSize的办法:

1
2
3
4
5
6
let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout
layout?.itemSize = CGSize(width: 200, height: 200)
print(layout?.itemSize)
/*
Optional((200.0, 200.0))
*/

这样的话,能够查看了,虽然上述代码也改变了itemSize的值,但却发现没有效果。是不是一定要用到下面collectionViewsizeForItemAt方法来实时变更itemSize的值?

看一下某个例子中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension GameViewController: UICollectionViewDelegateFlowLayout {
// 必须要让实现UICollectionViewController协议的类再遵循UICollectionViewDelegateFlowLayout协议,不然会无效!!!

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

if currentCardSizeValid {
return currentCardSize
}
currentCardSize = cardSize.getCardSize(collectionView: collectionView)
currentCardSizeValid = true
return currentCardSize
}

}

上面方法中,cardSize.getCardSize方法是自定义的用来计算尺寸的方法,此外,还多了一个flag就是currentCardSizeValid,为何要设置这个,因为多少个cell,就会计算多少次尺寸,为了节约资源,毕竟每个cell应该都是一样大小的(应该会有例外的情况),这时候只要沿用而不是重复计算才是最好的。

cardSize.getCardSize(collectionView: collectionView)的具体代码实现见下面。

如何更好地计算collectionCell的图片的合适大小

场景是:
比如给到图片的大小(这就能算到图片的比例),又给到34的排放顺序(43也是可以的,需要计算哪个更划算),这时候就需要根据屏幕是横屏还是竖屏,来最终决定图片的实际大小,也就是collectionCell的最终大小。

具体见文件 CardSize.swift,以及如何分配横竖排的关系的文件Grids.swiftGrid.swift

UIImagePickerController

This new class is designed to let users select an image from their camera to import into an app. When you first create a UIImagePickerController, iOS will automatically ask the user whether the app can access their photos.

注意:使用UIImagePickerController不需要取得用户的同意,因为它是整个被UIKit掌控的,iOS知道我们不会去滥用它,所以不需要取得用户的许可。

privacy permission

查看、使用用户的照片,需要对用户进行告知:
we need to add a text string describing our intent. So, open Info.plist, select any item, click +, then choose the key name “Privacy - Photo Library Additions Usage Description”. Give it the value “We need to import photos” then press return.

一般示例代码

1
2
3
4
5
6
7
8
9
10
11
12
// 先由一个按钮引出
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewPerson))

// 引出方法
@objc func addNewPerson() {
let picker = UIImagePickerController()
picker.allowsEditing = true
// 由于这行代码,需要本身的class必须遵循到UIImagePickerControllerDelegate protocol,
// 而且还要额外遵循 UINavigationControllerDelegate protocol.
picker.delegate = self
present(picker, animated: true)
}

用户选择照片后,会自动运行到一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
guard let image = info[.editedImage] as? UIImage else { return }

let imageName = UUID().uuidString
let imagePath = getDocumentsDirectory().appendingPathComponent(imageName)

if let jpegData = image.jpegData(compressionQuality: 0.8) {
try? jpegData.write(to: imagePath)
}

dismiss(animated: true)
}

func getDocumentsDirectory() -> URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return paths[0]
}

其中,info参数是一个dict,它有两个参数,分别是 .editedImage (the image that was edited) or .originalImage , but in our case it should only ever be the former unless you change the allowsEditing property.

UIImagePickerController实例的.sourceType

.sourceType 可取值 UIImagePickerController.camera, 表示从camera中拍照并取得照片。
但前提还得去判断是否开放该功能:UIImagePickerController.isSourceTypeAvailable(.camera)

1
2
3
4
5
6
7
8
9
// 先判断用户是不是允许从camera中拍摄并取得照片
if UIImagePickerController.isSourceTypeAvailable(.camera) {
// 省略用户选择从camera拍摄并取得照片的部分代码
let picker = UIImagePickerController()
picker.allowsEditing = true
picker.delegate = self
picker.sourceType = .camera
present(picker, animated: true)
}

详细的实现代码可供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// viewDidLoad()中的代码
addPersonButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewPerson))
navigationItem.leftBarButton = addPerButton

// addNewPerson方法
@objc func addNewPerson() {
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let ac = UIAlertController(title: "Source", message: nil, preferredStyle: .actionSheet)
ac.addAction(UIAlertAction(title: "Photos", style: .default, handler: { [weak self] _ in
self?.showPicker(fromCamera: false)
}))
ac.addAction(UIAlertAction(title: "Camera", style: .default, handler: { [weak self] _ in
self?.showPicker(fromCamera: true)
}))
ac.addAction(UIAlertAction(title: "Cancel", style: .cancel))
ac.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem

present(ac, animated: true)
}
else {
showPicker(fromCamera: false)
}
}

// showPicker方法
func showPicker(fromCamera: Bool) {
let picker = UIImagePickerController()
picker.allowsEditing = true
picker.delegate = self
if fromCamera {
picker.sourceType = .camera
}
present(picker, animated: true)
}

SpriteKit

(According to the project 11 from “100 hundred days of Swift”)

Create a new project in xCode and choose Game, sets its Game Technology to be Spritekit.

坐标系统的不同

1.UIKit的物体坐标都是基于一个view的左上角为起点的,而SpriteKit是基于物体的中心点。
2.UIKit的坐标系Y:0是基于屏幕的上边沿,而SpriteKit是基于屏幕的下边沿。

GameScene.sks 是类似于Interface Builder的

双击打开GameScene.sks,就打开了Scene Editor,这图形界面类似于Interface Builder。

SKScene

当建立一个project是基于Game的,那么GameScene.swift文件的GameScene这个class就是基于SKScene协议的。

didMove(to:) 类似于viewDidLoad()

在里面写入代码:

1
2
3
4
5
6
7
8
9
10
11
12
override func didMove(to view: SKView) {
// 建立一个背景图片的节点
let background = SKSpriteNode(imageNamed: "background.jpg")
// 图片的中心点是基于CGPoint坐标的,这里设置的是屏幕的中心点
background.position = CGPoint(x: 512, y: 384)
// The .replace option means "just draw it, ignoring any alpha values," which makes it fast for things without gaps such as our background.
background.blendMode = .replace
// 这个背景图片的节点在整个场景的z坐标的-1位置,其实就是放在最后面
background.zPosition = -1
// 这是用于给场景添加节点的方法
addChild(background)
}

override func didMove(to view: SKView) { }

这个类似于viewDidLoad方法。

override func update(_ currentTime: TimeInterval) { }

The update() method is called once every frame, and lets us make changes to our game.
Try not to do too much work, because it can slow your game down.

override func touchesBegan(_ touches: Set, with event: UIEvent?) { }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

// 每次触碰到屏幕就会进入循环
if let touch = touches.first {
// 取得触碰的位置坐标
let location = touch.location(in: self)
// 生成一个SKSpriteNode节点,该节点是基于CGSize生成的一个红色正方形
let box = SKSpriteNode(color: UIColor.red, size: CGSize(width: 64, height: 64))
// 该正方形所放置的位置
box.position = location
// 添加进场景
addChild(box)
}
}

override func touchesEnded(_ touches: Set, with event: UIEvent?) {}

Tells the responder when one or more fingers raised from a view or window.

override func touchesMoved(_ touches: Set, with event: UIEvent?) { }

测试下来,跟touchesBegan一样的用法,只是它只在触摸屏幕并移动时,才会被触发。
touchesMoved() is called when an existing touch changes position.

SKSpriteNode 类似于各个主页面的节点

建立一个正方形:

1
SKSpriteNode(color: UIColor.red, size: CGSize(width: 64, height: 64))

建立一个基于image文件的图形:

1
SKSpriteNode(imageNamed:)

好像不能直接建立一个圆形,是不是先要做一个圆形出来,随后再基于该圆形设计出该SKSpriteNode?
待后续碰到了再补充吧…
但下面代码中ball.physicsBody = SKPhysicsBody(circleOfRadius: ball.size.width / 2.0)就可以设计出一个圆形的物理属性来。

SKSpriteNode.texture -> SKTexture(imageNamed:)

class called SKTexture, which is to SKSpriteNode sort of what UIImage is to UIImageView – it holds image data, but isn’t responsible for showing it.

1
2
3
4
5
6
7
// 之前设定的charNode是一个SKSpriteNode,显示的是图片"penguinGood"
charNode = SKSpriteNode(imageNamed: "penguinGood")
// 但现在我想把charNode显示的图片改成是"penguinEvil"
// 这时只要更改charNode的材质,即charNode.texture
// open class SKSpriteNode : SKNode, SKWarpable {
// open var texture: SKTexture?
charNode.texture = SKTexture(imageNamed: "penguinEvil")

Changing the character node’s texture like this is helpful because it means we don’t need to keep adding and removing nodes. Instead, we can just change the texture to match what kind of penguin this is, then change the node name to match so we can do tap detection later on.

SKSpriteNode(texture:)

可以直接用SKTexture生成SKSpriteNode,而不需要再设置SKSpriteNode的texture:

1
2
3
4
// 先设置SKTexture
let skyTexture = SKTexture(imageNamed: "sky")
// 再生成SKSpriteNode
let sprite = SKSpriteNode(texture: skyTexture)

SKSpriteNode.colorBlendFactor = 1 && SkSpriteNode.color = .red

Only SKSpriteNode has a colorBlendFactor property !!!
SKSpriteNode.colorBlendFactor是一个CGFloat,从0.0到1.0,代表与原材质的颜色的混同程序,0.0代表颜色不会改变,就是原材质,1.0代表可以完全改变原材质的颜色。

1
2
3
4
let firework = SKSpriteNode(imageNamed: "rocket")
firework.colorBlendFactor = 1
// 这时可以设置其的颜色
fireworl.color = .cyan

SKShapeNode

SKShapeNode是SpriteKit的一个class,它可以让你在Game中方便且快捷地画出随意的图形,比如画圆形、线、长方形,之前接触到的就是使用贝塞尔图形来画画,在Project23中,水果忍者游戏中用来切水果划屏幕的那条线,它的容器就是SKShapeNode。

SKShapeNode有一个属性叫path,是用来描绘我们想要画的图形的。当path为nil,就什么都不画了;当path被设置为一个有效的路径的话,就可以按照SKShapeNode的设置来画图形了。
另外,SKShapeNode期望的path是一个CGPath属性,而我们使用UIBezierPath.cgPath就能够符合这一要求了。

1
2
3
4
5
6
7
let shape = SKShapeNode()
shape.path = UIBezierPath(roundedRect: CGRect(x: -128, y: -128, width: 256, height: 256), cornerRadius: 64).cgPath
shape.position = CGPoint(x: frame.midX, y: frame.midY)
shape.fillColor = UIColor.red
shape.strokeColor = UIColor.blue
shape.lineWidth = 10
addChild(shape)

水果忍者中那条划过屏幕的线

代码太多,还是看教程中的代码,读一遍就懂了。

https://www.hackingwithswift.com/example-code/games/how-to-create-shapes-using-skshapenode

SKPhysicsBody属性

感觉是给 场景 或 节点 添加物理属性范围的。

1
2
3
4
5
6
7
// box虽然有两个框架,就是上面设置的正方形
// 但还得给这个box设置一个物理框架,范围就是这个正方形的范围(也就是说也能设置个圆形物理框架吧吧)
box.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 64, height: 64))

// The second line of code adds a physics body to the whole scene that is a line on each edge, effectively acting like a container for the scene.
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)

还能添加圆形的球以及圆形的物理属性范围,
以及使用到 .resitution 的反弹属性,值范围为0-1的小数。

1
2
3
4
5
6
7
let ball = SKSpriteNode(imageNamed: "ballRed")
// ball的物理属性范围仍旧是其本身
ball.physicsBody = SKPhysicsBody(circleOfRadius: ball.size.width / 2.0)
// restitution是 恢复原状 的意思
ball.physicsBody?.restitution = 0.4
ball.position = location
addChild(ball)

设计一个有物理属性但不会跟着动的东西,简单说就是,我造了个东西,这东西被撞了却不会动,但撞它的其他东西会被弹开:
这里使用到了 .isDynamic :

1
2
3
4
5
let bouncer = SKSpriteNode(imageNamed: "bouncer")
bouncer.position = CGPoint(x: 512, y: 0)
bouncer.physicsBody = SKPhysicsBody(circleOfRadius: bouncer.size.width / 2.0)
bouncer.physicsBody?.isDynamic = false
addChild(bouncer)

SKPhysicsBody(texture: <#T##SKTexture#>, size: <#T##CGSize#>)

之前都是SKPhysicsBody(circleOfRadius),或者SKPhysicsBody(rectangleOf: <#T##CGSize#>)来定义,虽然对生成SKPhysicsBody的效率会有很大的提高,但有时候需要让该物体的每个pixel都作为SKPhysicsBody的时候,就需要用到SKPhysicsBody(texture: <#T##SKTexture#>, size: <#T##CGSize#>),虽然会降低效率,但有时是有必要的。
SpriteKit can create pixel-perfect collision detection by examining the pixels in a sprite’s texture.

1
2
3
// let sprite = SKSpriteNode(imageNamed: "enemy")

sprite.physicsBody = SKPhysicsBody(texture: sprite.texture!, size: sprite.size)

节点的physicsBody?.categoryBitMask属性

碰撞的时候,需要识别碰撞体,最简单的方式,是给节点设置一个name的值,但也可以给节点的physicsBody?.categoryBitMask添加值,这里的bit可以理解为是位运算的意思。
注意:UInt8,UInt16,UInt32,UInt64 分别表示 8 位,16 位,32 位 和 64 位的无符号整数形式。
比如:

1
2
3
4
5
// 设置物理体的标示符  <<左移运算符  左移一位,相当于扩大2倍
let birdCategory: UInt32 = 1 << 0 //1
let worldCategory: UInt32 = 1 << 1 //2
let pipeCategory: UInt32 = 1 << 2 //4
let scoreCategory: UInt32 = 1 << 3 //8

具体的例子见 ontactTestBitMask && collisionBitMask 知识点。(会带上上面的四个标识符一起使用)

节点的physicsBody?的velocity速度属性

可设置该节点的速度,比如在touchesBegan方法中,当用户点击屏幕,就对速度进行更改:

1
2
3
4
5
6
7
8
9
10
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 省略其他代码

// 本来因为重力等原因dy是负值,用户点击的同时,值为0
bird.physicsBody?.velocity = CGVector(dx: 0, dy: 0)
// 但一旦停止点击,dy的值又会变成负值,小鸟又会不断下降,这是因为
// self.physicsWorld.gravity = CGVector(dx: 0.0, dy: -3.0) 这个的预先设定

// 此时可以给其一个向上的速度或是力
bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 10))

节点的physicsBody?的applyImpulse方法

在 “节点的physicsBody?的velocity速度属性”知识点的例子中已经出现。

这里还有一个例子:

1
2
3
4
5
6
// 给香蕉一个自转速度和顺时针方向
banana.physicsBody?.angularVelocity = -20

// 之前给用户两个slider以设置角度(后换算成radians)和速度(speed),
let impulse = CGVector(dx: cos(radians) * speed, dy: sin(radians) * speed)
banana.physicsBody?.applyImpulse(impulse)

节点的physicsBody?的angularVelocity属性

angularVelocity 是指自身旋转的速度。

1
2
3
// sprite.physicsBody = SKPhysicsBody(texture: sprite.texture!, size: sprite.size)

sprite.physicsBody?.angularVelocity = 5

节点的physicsBody?的angularDamping属性

angularDamping 是指减少自身旋转的速度。

1
2
3
4
// sprite.physicsBody = SKPhysicsBody(texture: sprite.texture!, size: sprite.size)
// sprite.physicsBody?.angularVelocity = 5

sprite.physicsBody?.angularDamping = 2

节点的physicsBody?的linearDamping属性

linearDampin 是指减少linear上的移动速度。

1
2
3
4
// sprite.physicsBody = SKPhysicsBody(texture: sprite.texture!, size: sprite.size)
// sprite.physicsBody?.velocity = CGVector(dx: -500, dy: 0)

sprite.physicsBody?.linearDamping = 0

节点的physicsBody?的usesPreciseCollisionDetection属性

注意:Precise collision detection should be used rarely, and only generally with small, fast-moving objects.

1
2
3
4
5
6
7
8
banana = SKSpriteNode(imageNamed: "banana")
banana.name = "banana"
banana.physicsBody = SKPhysicsBody(circleOfRadius: banana.size.width / 2)
banana.physicsBody?.categoryBitMask = CollisionTypes.banana.rawValue
banana.physicsBody?.collisionBitMask = CollisionTypes.building.rawValue | CollisionTypes.player.rawValue
banana.physicsBody?.contactTestBitMask = CollisionTypes.building.rawValue | CollisionTypes.player.rawValue
banana.physicsBody?.usesPreciseCollisionDetection = true
addChild(banana)

示例游戏中,旋转的香蕉会撞向大楼,具体撞击的点会把大楼轰出一个半圆出来,这时候使用usesPreciseCollisionDetection属性,感觉是让返回的撞击点更加精确。

SKScene的physicsWorld属性

SKScene的physicsWorld.gravity–重力属性

其实感觉不能叫重力属性,因为你可以设置成各个方向都有引力。
self.physicsWorld.gravity是CGVector数据类型。

1
self.physicsWorld.gravity = CGVector(dx: 0.0, dy: -3.0)

以上代码,代表x方向没有正引力也没有负引力,y方向有3个负引力。

SKScene的physicsWorld.contactDelegate 代理

物理世界的碰撞检测代理一般都设置为场景自己:

1
self.physicsWorld.contactDelegate = self

此时场景需要遵循 SKPhysicsContactDelegate 的代理协议。

SKAction

SKAction.setTexture()

给予SKNode一个新的Texture,目的是为了在SKAction.sequence中有一个动画效果。
例如:

1
2
3
4
5
6
7
8
banana.physicsBody?.angularVelocity = -20

// player1 = SKSpriteNode(imageNamed: "player")
let raiseArm = SKAction.setTexture(SKTexture(imageNamed: "player1Throw"))
let lowerArm = SKAction.setTexture(SKTexture(imageNamed: "player"))
let pause = SKAction.wait(forDuration: 0.15)
let sequence = SKAction.sequence([raiseArm, pause, lowerArm])
player1.run(sequence)

dropBanana

SKAction.rotate

先加载一个图形:

1
var slotGlow: SKSpriteNode = SKSpriteNode(imageNamed: "slotGlowGood")

是这个样子的:
slotGlowGood@2x

我们要让它沿着中心点始终在转:

1
2
3
4
5
6
7
8
9
// 如何运动:
// 1.旋转 -- rotate
// 2.角度一个pi,即180度
// 3.时间为10秒,就是旋转的快慢
let spin = SKAction.rotate(byAngle: .pi, duration: 10)
// 始终在旋转不停止
let spinForever = SKAction.repeatForever(spin)
// 让这个slotGlow运行这个运动模式
slotGlow.run(spinForever)

SKAction.moveBy(x:y:duration:)

移动位置及持续时间。

1
2
3
// charNode = SKSpriteNode(imageNamed: "penguinGood")
// charNode已可在主画面显示
charNode.run(SKAction.moveBy(x: 0, y: 80, duration: 0.05))

SKAction.wait(forDuration:)

等待。以秒计算。

1
let delay = SKAction.wait(forDuration: 0.25)

SKAction.animate(with: <#T##[SKTexture]#>, timePerFrame: <#T##TimeInterval#>)

比如做一个小鸟飞翔的动画,可以拿到的小鸟的照片分别是bird-01/bird-02/bird-03,图像分别是:
bird-01bird-02bird-03
代码是:

1
2
3
4
5
6
7
8
9
10
11
// bird = SKSpriteNode(imageNamed: "bird-01")

let birdTexture1 = SKTexture(imageNamed: "bird-01")
birdTexture1.filteringMode = .nearest
let birdTexture2 = SKTexture(imageNamed: "bird-02")
birdTexture2.filteringMode = .nearest
let birdTexture3 = SKTexture(imageNamed: "bird-03")
birdTexture3.filteringMode = .nearest
let anim = SKAction.animate(with: [birdTexture1,birdTexture2,birdTexture3], timePerFrame: 0.2)
bird.run(SKAction.repeatForever(anim), withKey: "fly")

SKAction.run(block:)

SKAction.run(block:) will run any code we want, provided as a closure. “Block” is Objective-C’s name for a Swift closure.

1
let notVisible = SKAction.run { [unowned self] in self.isVisible = false }

为何要用SKAction.run来执行这段代码,而不是直接去执行呢?
感觉是因为类似下面的SKAction.sequence()需要依序执行action和改变一些属性的情况。

SKSpriteNode.run(<#T##action: SKAction##SKAction#>, withKey: <#T##String#>)

这里出现一个withKey参数,可以这么设置:

1
bird.run(SKAction.repeatForever(anim), withKey: "fly")

如果要停止这个动画,就可以使用到:

1
bird.removeAction(forKey: "fly")

还有一个让SKLabelNode显示的文字瞬间变大又还原的案例:

1
scoreLabelNode.run(SKAction.sequence([SKAction.scale(to: 1.5, duration: TimeInterval(0.1)), SKAction.scale(to: 1.0, duration: TimeInterval(0.1))]))

SKLabelNodeScale

SKSpriteNode.removeAction(forKey: <#T##String#>)

紧接着上面的例子,用法是:

1
bird.removeAction(forKey: "fly")

SKAction.sequence()

SKAction.sequence() takes an array of actions, and executes them in order. Each action won’t start executing until the previous one finished.

1
2
3
4
5
6
7
8
func hit() {
isHit = true

let delay = SKAction.wait(forDuration: 0.25)
let hide = SKAction.moveBy(x: 0, y: -80, duration: 0.5)
let notVisible = SKAction.run { [unowned self] in self.isVisible = false }
charNode.run(SKAction.sequence([delay, hide, notVisible]))
}

SKAction.group()

An action group specifies that all actions inside it should execute simultaneously.
这里要注意的一点是,SKAction.group()内的SKAction,是同时执行的,所以我们平时可以配合SKAction.sequence()一起使用,但要注意两者的明显不同之处。

1
2
3
4
5
6
let scaleOut = SKAction.scale(to: 0.001, duration: 0.2)
let fadeOut = SKAction.fadeOut(withDuration: 0.2)
let group = SKAction.group([scaleOut, fadeOut])

let seq = SKAction.sequence([group, .removeFromParent()])
node.run(seq)

背景闪电的案例

使用到 SKAction.sequence() / SKAction.run / SKAction.wait / SKScene.run
效果图:
backgroundcolorFlash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// var skyColor = SKColor(red: 81.0/255.0, green: 192.0/255.0, blue: 201.0/255.0, alpha: 1.0)
// self.backgroundColor = skyColor

// 当系统检测到特定碰撞时,即调用该bgFlash方法:
func bgFlash() {
let bgFlash = SKAction.run ({
self.backgroundColor = SKColor(red: 1, green: 0, blue: 0, alpha: 1.0)
})
let bgNormal = SKAction.run ({
self.backgroundColor = self.skyColor
})
let bgFlashAndNormal = SKAction.sequence([bgFlash, SKAction.wait(forDuration: TimeInterval(0.05)), bgNormal, SKAction.wait(forDuration: TimeInterval(0.05))])
self.run(SKAction.sequence([SKAction.repeat(bgFlashAndNormal, count: 4)]), withKey: "flash")
// 加了下面这行代码就没了效果,感觉是异步的原因,
// 上一行代码还没执行完,下一行就开始执行了,所以无效了。
// self.removeAction(forKey: "flash")
}

“Game Over” 字样从天而降 的案例

使用到SKScene的默认Bool属性isUserInteractionEnabled,值为true。

以下例子是在游戏结束的瞬间:
1、玩家不能点击屏幕;
2、Game Over 字样从天而降;
3、玩家此时才能点击屏幕得到响应。

GameOver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//lazy var gameOverLabel: SKLabelNode = {
// let label = SKLabelNode(fontNamed: "Chalkduster")
// label.text = "Game Over"
// return label
//}()

// SKScene的默认属性isUserInteractionEnabled为true,此时用户是可以随便点击屏幕并有响应的
isUserInteractionEnabled = false
addChild(gameOverLabel)
gameOverLabel.position = CGPoint(x: self.size.width * 0.5, y: self.size.height)

let delay = SKAction.wait(forDuration: TimeInterval(1))
let move = SKAction.move(by: CGVector(dx: 0, dy: -self.size.height * 0.5), duration: 1)
gameOverLabel.run(SKAction.sequence([delay, move]), completion: {
self.isUserInteractionEnabled = true
})

SKAction.playSoundFileNamed()

SKAction.playSoundFileNamed()是在SKAction中用来播放声音的。

The three main sound file formats you’ll use are MP3, M4A and CAF, with the latter being a renamed AIFF file. AIFF is a pretty terrible file format when it comes to file size, but it’s much faster to load and use than MP3s and M4As, so you’ll use them often.

1
run(SKAction.playSoundFileNamed("whackBad.caf", waitForCompletion: false))

这里是直接run,其实是因为func是在GameScene中,所以可以直接用run。而若你是在其他SKNode中,个人认为是可以使用SKNode.run()的。
注意:这里的run不同于SKAction.run(block:)

SKaction.follow(<#T##CGPath#>, asOffset: <#T##Bool#>, orientToPath: <#T##Bool#>, speed: <#T##CGFloat#>)

意思是action是跟随者着CGPath的路径进行。

1
2
3
4
5
6
7
let path = UIBezierPath()
path.move(to: .zero)
path.addLine(to: CGPoint(x: xMovement, y: 1000))

// orientToPath 的意思是跟着路径一起旋转角度。
let move = SKAction.follow(path.cgPath, asOffset: true, orientToPath: true, speed: 200)
node.run(move)

给这个node设置了一个SKAction,这个SKAction是跟随这贝塞尔曲线(但上面确实直线,应该是跟上面的path有关系)运动。

The follow() method takes three other parameters, all of which are useful. The first decides whether the path coordinates are absolute or are relative to the node’s current position. If you specify asOffset as true, it means any coordinates in your path are adjusted to take into account the node’s position.
The third parameter to follow() is orientToPath and makes a complicated task into an easy one. When it’s set to true, the node will automatically rotate itself as it moves on the path so that it’s always facing down the path. Perfect for fireworks, and indeed most things! Finally, you can specify a speed to adjust how fast it moves along the path.

SKPhysicsContactDelegate

SKPhysicsContactDelegate协议 就类似于针对物理碰撞的处理器,所以一般都是符合SKScene协议的class(比如例子里就是GameScene) 需要另外去遵循的:

1
class GameScene: SKScene, SKPhysicsContactDelegate {

此外,在didMove(to:)中我们需要添加的:

1
2
3
4
5
6
7
8
9
override func didMove(to view: SKView) {
// 之前这行代码,就是让整个屏幕作为一个物理属性范围
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)

// 让这整理屏幕作为一个物理属性范围,那么它的碰撞处理器是什么呢?
// 是self,就是 class GameScene: SKScene, SKPhysicsContactDelegate {
// 这也是GameScene为什么要遵循SKPhysicsContactDelegate协议的原因
physicsWorld.contactDelegate = self

contactTestBitMask && collisionBitMask

The collisionBitMask bitmask means “which nodes should I bump into?” By default, it’s set to everything, which is why our ball are already hitting each other and the bouncers. The contactTestBitMask bitmask means “which collisions do you want to know about?” and by default it’s set to nothing. So by setting contactTestBitMask to the value of collisionBitMask we’re saying, “tell me about every collision.”

  • The categoryBitMask property is a number defining the type of object this is for considering collisions.
  • The collisionBitMask property is a number defining what categories of object this node should collide with,
  • The contactTestBitMask property is a number defining which collisions we want to be notified about.

两者的默认设置值是什么?
collisionBitMask的默认值是”everything”,contactTestBitMask的默认值是”nothing”。所以,能得出结论是,两者会碰撞,但永远收不到碰撞的相关通知。

所以下面的代码:

1
ball.physicsBody!.contactTestBitMask = ball.physicsBody!.collisionBitMask           

ball.physicsBody!.collisionBitMask代表 球 的所有碰撞情况,前半句ball.physicsBody!.contactTestBitMask的意思是,哪些是需要进行上报的碰撞。
整个连起来就是,每个球的所有碰撞都得上报碰撞事件。

下面是一个完整的关于多个物体碰撞的例子:

1
2
3
4
5
// 设置物理体的标示符  <<左移运算符  左移一位,相当于扩大2倍
let birdCategory: UInt32 = 1 << 0 //1 二进制表示是1
let worldCategory: UInt32 = 1 << 1 //2 二进制表示是10
let pipeCategory: UInt32 = 1 << 2 //4 二进制表示是100
let scoreCategory: UInt32 = 1 << 3 //8 二进制表示是1000

给每个物理体设置physicsBody?.categoryBitMask的标识符:

1
2
3
4
5
6
7
8
// 给地面添加一个识别
ground.physicsBody?.categoryBitMask = worldCategory
// 给鸟添加一个识别
bird.physicsBody?.categoryBitMask = birdCategory
// 给管道添加一个识别
pipe.physicsBody?.categoryBitMask = pipeCategory
// 给得分墙添加一个识别
contactNode.physicsBody?.categoryBitMask = scoreCategory

给所有对于鸟可能发生的碰撞进行定义:

1
2
3
4
bird.physicsBody?.collisionBitMask = worldCategory | pipeCategory | scoreCategory 
// 或运算,答案是14, 二进制表示是1110
// 上面这行代码可以不写的,因为你不定义collisionBitMask的话,collisionBitMask就代表所有会发生的碰撞。
// 但这样把所有可能发生的碰撞全写上,毕竟是笔记,尽量要全一点

给所有对于鸟来说需要上报通知的碰撞进行定义:

1
2
3
4
bird.physicsBody?.contactTestBitMask = worldCategory | pipeCategory | scoreCategory
// 把以上三种会碰到的碰撞规定需要上报
// 这三种碰撞是所有可能的碰撞,所以代码也可以这样写:
// bird.physicsBody?.contactTestBitMask = bird.physicsBody?.collisionBitMask

以上是对鸟可能发生的碰撞以及需要上报通知的定义,你完全可以去定义 地面/管道/得分墙 的可能发生的碰撞以及需要上报通知的情况,
毕竟这里代码参考的文章中就是这么写的。

https://www.jianshu.com/p/bc22ee0f87b4
但觉得没必要,你只要定义鸟的collisionBitMask和contactTestBitMask部分就可以了,如果遇到更复杂的情况,倒是可以对类似 地面/管道/得分墙 进行细节上的再定义。

所以当碰撞发生的时候就可以去判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func didBegin(_ contact: SKPhysicsContact) {
// 当上报的是scoreCategory,就知道是和得分墙的碰撞,这是就要进行加分等操作了
if contact.bodyA.categoryBitMask == scoreCategory || contact.bodyB.categoryBitMask == scoreCategory {
score += 1
scoreLabelNode.text = String(score)
scoreLabelNode.run(SKAction.sequence([SKAction.scale(to: 1.5, duration: TimeInterval(0.1)), SKAction.scale(to: 1.0, duration: TimeInterval(0.1))]))
} else {
// 当不是与得分墙的碰撞的话,就是鸟与管道或者地面的相撞了
// collisionBitMask 设置成 worldCategory,是防止与管道再次发生相撞,让其直接掉到地上,
// 若设置为0,因为标识符没有0的,就会发生一种情况,
// 那就是小鸟直接往下掉,掉出屏幕!
bird.physicsBody?.collisionBitMask = worldCategory
overStatus()
bgFlash()
}
}

但是,这样设置还是出现了问题,问题就是,当鸟撞上了得分墙了以后,它就被堵在了那里不能动了,所以鸟的collisionBitMask和contactTestBitMask都要去除scoreCategory:

1
2
bird.physicsBody?.collisionBitMask = worldCategory | pipeCategory
bird.physicsBody?.contactTestBitMask = worldCategory | pipeCategory

但是去除后,就不会有和得分墙的碰撞,也就无法产生得分了。
这时候就要在得分墙上动脑筋了:

1
contactNode.physicsBody?.contactTestBitMask = birdCategory

对于鸟的碰撞,contactNode需要去通知汇报碰撞情况,而此时因为鸟是被动一方,所以继续畅通无阻地通过这堵得分墙。

对于这个问题,总结一点,就是鸟设置的碰撞排除掉空气墙,而空气墙设置的碰撞要针对鸟,这样就行了。

设置了contactTestBitMask,但没设置collisionBitMask,会发生什么?

实际没有操作过,教程中称两者不会有碰撞反应,但两者相交的时候会告知。

contactTestBitMask / collisionBitMask / categoryBitMask 三个属性的建议定义格式

SpriteKit希望我们使用UInt32格式来定义上面三个属性。

一般定义的用例:

1
2
3
4
5
6
7
enum CollisionTypes: UInt32 {
case player = 1
case wall = 2
case star = 4
case vortex = 8
case finish = 16
}

定义了上述三个属性,如何使用?
直接使用CollisionTypes.player来赋值可以吗?显然不行,因为它的值是player,而非SpriteKit要求的UInt32属性的值1。因此,需要用到CollisionTypes.player.rawValue

1
2
3
4
5
6
7
let node = SKSpriteNode(imageNamed: "block")
node.position = position

node.physicsBody = SKPhysicsBody(rectangleOf: node.size)
node.physicsBody?.categoryBitMask = CollisionTypes.wall.rawValue
node.physicsBody?.isDynamic = false
addChild(node)

func didBegin(_ contact: SKPhysicsContact) {

这是SKPhysicsContactDelegate默认会有的一个方法。
如果发生碰撞并上报事件了,就会调用该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 之前要给需要的SKSpriteNode节点取名字,比如
// slotBase.name = "good"
// slotBase.name = "bad"
// ball.name = "ball"
// 这里多说一句,可以给节点取名字,也可以给节点的physicsBody?.categoryBitMask添加值,这样更好,
// 具体见 physicsBody?.categoryBitMask的讲解。

func didBegin(_ contact: SKPhysicsContact) {
if contact.bodyA.node?.name == "ball" {
collisionBetween(ball: contact.bodyA.node!, object: contact.bodyB.node!)
} else if contact.bodyB.node?.name == "ball" {
collisionBetween(ball: contact.bodyB.node!, object: contact.bodyA.node!)
}
}

func collisionBetween(ball: SKNode, object: SKNode) {
if object.name == "good" {
destroy(ball: ball)
score += 1
} else if object.name == "bad" {
destroy(ball: ball)
score -= 1
}
}

func destroy(ball: SKNode) {
print("here")
if let fireParticles = SKEmitterNode(fileNamed: "FireParticles") {
fireParticles.position = ball.position
addChild(fireParticles)
}

let a = SKEmitterNode(fileNamed: "FireParticles")

// SKSpriteNode节点的移除一定要从Parent处移除
ball.removeFromParent()
}

SKLabelNode

The SKLabelNode class is somewhat similar to UILabel in that it has a text property, a font, a position, an alignment, and so on.

1
2
3
4
5
6
7
// 创建一个SKLabelNode,字体使用"粉笔灰"
scoreLabel = SKLabelNode(fontNamed: "Chalkduster")
// SKLabelNode的内容
scoreLabel.text = "Score: 0"
scoreLabel.horizontalAlignmentMode = .right
scoreLabel.position = CGPoint(x: 980, y: 700)
addChild(scoreLabel)

open func nodes(at p: CGPoint) -> [SKNode]

这方法是SKNode协议下的一个内置方法,作用是反馈在这个location的所有SKNode节点

1
2
3
4
5
6
7
8
9
10
11
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
let location = touch.location(in: self)

let objects = nodes(at: location)

if objects.contains(editLabel) {

} else {

}
1
2
3
4
5
6
7
8
9
10
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
var location = touch.location(in: self)

for node in nodes(at: location) {
if node.name == "player" {

}
}
}

SKEmitterNode

https://www.hackingwithswift.com/read/11/7/special-effects-skemitternode

SpriteKit has a built-in particle editor to help you create effects like fire, snow, rain and smoke almost entirely through a graphical editor.

The SKEmitterNode class is new and powerful: it’s designed to create high-performance particle effects in SpriteKit games, and all you need to do is provide it with the filename of the particles you designed and it will do the rest.

SKEmitterNode(fileNamed:)的用例

1
2
3
4
5
6
7
8
9
func destroy(ball: SKNode) {
// 加载FireParticles.sks文件
if let fireParticles = SKEmitterNode(fileNamed: "FireParticles") {
fireParticles.position = ball.position
addChild(fireParticles)
}

ball.removeFromParent()
}

就会有在ball被清除前,先出现一个爆炸的动画。
在xCode中你可以点击该FireParticles.sks文件,就可以看见右侧面板上有标题叫”SpriteKit Particle Emitter”,可以在里面更改动画效果等。

SKEmitterNode的advanceSimulationTime(sec: TimeInterval)

让SKEmitterNode提前多少秒渲染。

用例:

1
2
starField = SKEmitterNode(fileNamed: "starfield")!
starField.position = CGPoint(x: 1024, y: 384)

效果是这样的:
WithoutAdvanceSimulationTime
你会发现星空是从右边屏幕开始慢慢渲染的,但我们需要星空一开始就充满屏幕,这时候就需要用到SKEmitterNode的advanceSimulationTime(sec: TimeInterval)。

1
2
// 提前渲染10秒的星空
starField.advanceSimulationTime(10)

效果是这样的:
WithoutAdvanceSimulationTime

.sks文件的制作

还没涉及到.sks文件的制作,上例中,文件制作完预览是这样的:
FireParticles

文章中提了一笔如何创建该sks文件:
Add a new file, but this time choose “Resource” under the iOS heading, then choose “SpriteKit Particle File” to see the list of options.

后续需要研究下怎么制作……………………….

SKNode

SKNode doesn’t draw images like sprites or hold text like labels; it just sits in our scene at a position, holding other nodes as children.
SKSpriteNode, SKLabelNode and SKEmitterNode, and they all come from SKNode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class WhackSlot: SKNode {
// 这里没有写init()方法,但可以有
// configure方法是自己写的,并不是本身固有的方法
func configure(at position: CGPoint) {
// 确定SKNode在什么位置
self.position = position

// 确定这个SKNode显示什么图片
let sprite = SKSpriteNode(imageNamed: "whackHole")
addChild(sprite)

// 还可以加入SKCropNode让这里有个遮罩之类
// 再给SKCropNode加入一个SKSpriteNode(imageNamed:)之类的
// 再实现其的动画效果
// 具体实现见https://www.hackingwithswift.com/read/14/2/getting-up-and-running-skcropnode
}
}

SKNode.removeAllChildren()

移除SKNode中的所有子节点。

1
2
3
// var pipes: SKNode!

pipes.removeAllChildren()

SKNode.children – 获取所有节点

children可以获取到所有的节点,它的定义是: [SKNode]。
下面是获取到所有节点,并始终刷新,判断节点的x坐标在屏幕左侧-300的位置时,即将其删除的例子:

1
2
3
4
5
6
7
override func update(_ currentTime: TimeInterval) {
for node in children {
if node.position.x < -300 {
node.removeFromParent()
}
}
}

isUserInteractionEnabled 属性 – 是否让用户点击交互

一个SKNode,如果只是做背景之类不需要用户点击进行交互的话,就可以:

1
2
3
// skNode: SKNode

skNode.isUserInteractionEnabled = false

speed – property

speed is the property that all spritekit nodes can have.
你可以把speed属性理解成时间的乘数,当它是1.0的时候是正常的默认值,也就是实际的正常时间,而当你把它设置成2.0时,就能把时间设置成比正常时间快两倍。
当我们把speed设置成0时,所有的children都会变成静止的状态。

SKCropNode

SKCropNode is a special kind of SKNode subclass that uses an image as a cropping mask: anything in the colored part will be visible, anything in the transparent part will be invisible.
SKCropNode是SKNode的子类。
SKCropNode.maskNode得是个SKSpriteNode。
SKCropNode.maskNode的作用就是:
在SKCropNode.maskNode范围内,这是前提,
SKCropNode这个node中的texture(素材),必须是要出现在maskNode范围内的才会显示。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class WhackSlot: SKNode {

func configure(at position: CGPoint) {
self.position = position

let sprite = SKSpriteNode(imageNamed: "whackHole")
addChild(sprite)

let cropNode = SKCropNode()
cropNode.position = CGPoint(x: 0, y: 15)
// 把cropNode放在其他node之上
cropNode.zPosition = 1
// 若设置成cropNode.maskNode = nil 则charNode就会显示出来
cropNode.maskNode = SKSpriteNode(imageNamed: "whackMask")

charNode = SKSpriteNode(imageNamed: "penguinGood")
charNode.position = CGPoint(x: 0, y: -90)
charNode.name = "character"
cropNode.addChild(charNode)

addChild(cropNode)
}
}

解释下代码:
cropNode的位置在(0,15), cropNode.maskNode 是一张图片,可以理解成是cropNode的背景图,但实施其是一个人mask,在mask内的显示,不在的不显示。
charNode的位置在(0,-90),它在 cropNode.maskNode 的下面,所以不显示这个charNode。
后续会有代码让charNode往上升,就能看到了,不过这是后话。

I hope you noticed the important thing, which is that the character node is added to the crop node, and the crop node was added to the slot. This is because the crop node only crops nodes that are inside it, so we need to have a clear hierarchy: the slot has the hole and crop node as children, and the crop node has the character node as a child.

AnchorPoint

在SpriteKit的游戏开发中经常会使用到AnchorPoint锚点这一属性,这需要配合position属性一起使用。
在SpriteKit中(0,0)这个点是在左下角,但UIKit中是左上角。
锚点的类型是CGPoint类型数据,取值范围是(0,0)~(1,1)之间,如下图:
AnchorPoint

我们以(0,0)为左上角来讲解:
View的position为(50,50):
1、 若AnchorPoint为(0.5,0.5), 就说明锚点取的是View的中心点, 则View的中心点位置是(50,50), 如下图:
AnchorPoint
2、若AnchorPoint为(0,0),就说明锚点取的是View的左上角,则View的左上角位置是(50,50),如下图:
AnchorPoint
3、若AnchorPoint为(1,1),就说明锚点取的是View的右下角,则View的右下角位置是(50,50),如下图:
AnchorPoint

UIViewController 与 GameScene 的关系

https://www.hackingwithswift.com/read/29/3/mixing-uikit-and-spritekit-uislider-and-skview

默认建立game后,会有GameScene.swift和GameViewController.swift这两个文件,要解释一下的是,GameViewController.swift中的UIViewController,掌管着GameScene.swift中的GameScene。

我们一般采用的方式是,让两者互相通信,即可互相引用,但有一个强弱关系,GameViewController中的属性指向GameScene是一个强引用,而GameScene中的属性指向GameViewController的是一个弱引用,防止内存占用崩溃的情况。比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// GameScene.swift
class GameScene: SKScene {
weak var viewController: GameViewController!
}

// GameViewController.swift
class GameViewController: UIViewController {
var currentGame: GameScene!

override func viewDidLoad() {
super.viewDidLoad()

if let view = self.view as! SKView? {
if let scene = SKScene(fileNamed: "GameScene") {
scene.scaleMode = .aspectFill

view.presentScene(scene)

// 注意:主要是下面这两行来进行引用
currentGame = scene as? GameScene
currentGame.viewController = self
}

view.ignoresSiblingOrder = true

view.showsFPS = true
view.showsNodeCount = true
}

}

}

这样设置后,两者就可以正常通信了。

UIViewController的默认方法

override func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { } 检测设备的 晃动/shake/摇晃 效果

motionBegan()只能在view controller中设置,而不能在game scenes中设置。
下面是在GameViewController.swift的UIViewController中建立一个方法,来调用GameScene.swift中的explodeFireworks方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class GameViewController: UIViewController {
// 省略必须的代码

override func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
// 感觉这行代码是检测主体的view是否是一个SKView
guard let skView = view as? SKView else { return }
// 感觉这行代码是检测SKView的scene是否是GameScene
guard let gameScene = skView.scene as? GameScene else { return }
// 如果上面检测都成立,就运行GameScene.swift文件中的GameScene类中的explodeFireworks方法。
gameScene.explodeFireworks()
}

}

注:在模拟器中使用”ctrl”+”cmd”+”z”来模拟设备的晃动效果。

如何在游戏中新生成GameScene,以及在此过程中与GameViewController.swift之间的联系

在游戏中,比如玩家输掉的情况下,需要重新生成GameScene的场景,如果去做呢?
因为GameScene是由GameViewController来控制生成的,所以最终还是由后者来决定。
结合上面讲到的GameScene与GameViewController互相之间强弱引用自见的关系,具体见UIViewController 与 GameScene 的关系
简单代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以下代码在GameScene中运行

// 先生成新的游戏场景
let newGame = GameScene(size: self.size)

// SKTransition.crossFade 可以让一个scene过渡到另一个scene,
// 我的理解就是通过慢慢让一个scene消失,再让另一个scene出现.
let transition = SKTransition.crossFade(withDuration: 2)
// 例子中使用的是SKTransition.doorway(withDuration:)的方法,
// 查了下说明,说是像一个打开的大门一样让scene消失掉.

// 这时候需要使用到UIViewController了,其的view?.presentScene的作用就是,
// 传递一个新的scene,并让这个过滤通过你定义的transition来展现。
self.view?.presentScene(newGame, transition: transition)

在spriteKit中使用UIGraphicsImageRenderer生成图像的注意点

先看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class GameScene: SKScene {
var spriteKitNode: SKSpriteNode!

override func didMove(to view: SKView) {

let renderer = UIGraphicsImageRenderer(size: CGSize(width: 200, height: 200))
let img = renderer.image { ctx in
UIColor.red.setFill()
let rect = CGRect(x: 0, y: 0, width: 200, height: 200)
ctx.cgContext.addRect(rect)
ctx.cgContext.drawPath(using: .fill)

UIColor.systemBrown.setFill()
let rect2 = CGRect(x: 0, y: 0, width: 100, height: 100)
ctx.cgContext.addEllipse(in: rect2)
ctx.cgContext.drawPath(using: .fill)

UIColor.systemGreen.setFill()
let rect3 = CGRect(x: 100, y: 100, width: 100, height: 100)
ctx.cgContext.addRect(rect3)
ctx.cgContext.drawPath(using: .fill)
}

spriteKitNode = SKSpriteNode(texture: SKTexture(image: img))
spriteKitNode.position = CGPoint(x: 100, y: 100)

addChild(spriteKitNode)
}
}

在手机中生成的图像是这样的:
spriteKitAndUIGraphicsImageRenderer

总结:

  1. SpriteKit中的坐标是以左下角为(0,0),而UIGraphicsImageRenderer是以左上角为(0,0)的;
  2. SpriteKit中的图形的坐标都是以图形的中心点为准的,比如上面的整个图形的坐标是(100,100),所以整个图形是生成在屏幕的左下脚,而在UIGraphicsImageRenderer中制作图形时,图形的左上角才是(0,0),比如大红正方形的坐标是(0,0),圆形的坐标也是(0,0),绿色小正方形的坐标是(100,100)。
  3. 两者使用不同的图形原点,所以在制图时要考虑到你是在哪个里面,是还在UIGraphicsImageRenderer中作画呢,还是已经跳出到SpriteKit中开始生成图形了。

ctx.cgContent.setBlendMode() 方法 – 展现炸掉上层图像的效果

基于上面一样的例子,这里背景色设定为白色,先看效果图:
setBlendMode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 其他代码一样

UIColor.systemBrown.setFill()
let rect2 = CGRect(x: 0, y: 0, width: 100, height: 100)
ctx.cgContext.addEllipse(in: rect2)
ctx.cgContext.setBlendMode(.clear)
ctx.cgContext.drawPath(using: .fill)

UIColor.systemGreen.setFill()
let rect3 = CGRect(x: 100, y: 100, width: 100, height: 100)
ctx.cgContext.addRect(rect3)
ctx.cgContext.setBlendMode(.color)
ctx.cgContext.drawPath(using: .fill)

// 一旦设置了ctx.cgContext.setBlendMode(.clear),下面的图像生成都是这个模式,
// 所以后面还要ctx.cgContext.setBlendMode(.color)再切回来。

SKTransition

SKTransition的定义是A transition style from one scene to another.

SKTransition.crossFade 与 SKTransition.doorway

都是让scene消失掉的过渡过程,例子具体见如何在游戏中新生成GameScene,以及在此过程中与GameViewController.swift之间的联系这一节。

SKAudioNode

SKAudioNode is good because it lets us stop the audio whenever we want.
One of the neat features of SKAudioNode is that it loops its audio by default. This makes it perfect for background music: we create the music, add it directly to the game scene as a child, and it plays our background music forever.

1
2
3
4
5
6
7
8
9
var backgroundMusic: SKAudioNode!

if let musicURL = Bundle.main.url(forResource: "music", withExtension: "m4a") {
backgroundMusic = SKAudioNode(url: musicURL)
addChild(backgroundMusic)
}

// 停止背景音乐
backgroundMusic.run(SKAction.stop())

NSCoding

https://www.hackingwithswift.com/read/12/3/fixing-project-10-nscoding

一般都推荐使用Codable了,所以这里就不写关于NSCoding的笔记了。

UISlider

如何取得滑动值

1
2
3
4
5
6
// 在storyboard上建立一个slider
@IBOutlet var intensity: UISlider!

// 如何取得UISlider的滑动数值
// 使用intensity.value即可
intensity.value

如何跟踪滑动值的改变

1
2
3
4
5
// 在storyboard上建立对应该Slider的action事件
// 每次滑动Slider都会触发该方法
@IBAction func intensityChanged(_ sender: Any) {

}

还有跟踪值变化的方法,就是下面对valueChanged改变的响应事件的方法。

UISlider的基本属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//  最小值
slider.minimumValue = 0.0
// 最大值
slider.maximumValue = 100.0
// 设置默认值
slider.setValue(sliderDefalutValue, animated: true)
// 滑动条有值部分颜色
slider.minimumTrackTintColor = .orange
// 滑动条没有值部分颜色
slider.maximumTrackTintColor = .black
// 滑块滑动的值变化触发ValueChanged事件 如果设置为滑动停止才触发则设置为false
slider.isContinuous = true
// 响应事件
slider.addTarget(self, action: #selector(sliderValueChange), for: .valueChanged)
// 修改控制器图片 -- 划动的图标改成自定义的图片
slider.setThumbImage(UIImage(named: "diamond"), for: .normal)

UISlider 与 animation 的配合使用

具体见UIViewPropertyAnimator的fractionComplete属性 -- 配合UISlider的使用这一章节的笔记。

CoreImage

Core Image is Apple’s high-speed image manipulation tookit. It does only one thing, which is to apply filters to images that manipulate them in various ways.

需要import

1
import CoreImage

CIContext

The first is a Core Image context, which is the Core Image component that handles rendering. We create it here and use it throughout our app, because creating a context is computationally expensive so we don’t want to keep doing it and it’s best to re-use them where possible.

1
2
3
4
var context: CIContext!

// viewDidLoad()
context = CIContext()

具体的例子见下面的CIFilter的讲解。

CIFilter

https://www.hackingwithswift.com/read/13/4/applying-filters-cicontext-cifilter
(以上有多种filter的选择,对应值的转换,以及项目实际模拟出来的各种效果,如果需要的时候可以看一下)
(也可以在用户导入照片的时候,提供必要的filter让其修改下照片效果等)

The second is a Core Image filter, and will store whatever filter the user has activated. This filter will be given various input settings before we ask it to output a result for us to show in the image view.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var currentFilter: CIFilter!

// viewDidLoad()
context = CIContext()
// creates an example filter that will apply a sepia tone effect to images.
currentFilter = CIFilter(name: "CISepiaTone")

// The CIImage data type is UIImage.
let beginImage = CIImage(image: currentImage)
// we send the result into the current Core Image Filter using the kCIInputImageKey. There are lots of Core Image key constants like this; at least this one is somewhat self-explanatory!
currentFilter.setValue(beginImage, forKey: kCIInputImageKey)

// The first line safely reads the output image from our current filter.
guard let image = currentFilter.outputImage else { return }
// The second line uses the value of our intensity slider to set the kCIInputIntensityKey value of our current Core Image filter. For sepia toning a value of 0 means "no effect" and 1 means "fully sepia."
// var intensity: UISlider!
currentFilter.setValue(intensity.value, forKey: kCIInputIntensityKey)

// 下面的 if 语句中的代码,我们也可以总结出:
// 要想从CIImage -> UIImage, 必须是:
// CIImage -> CGImage -> UIImage.
// -----------------------------------
// it creates a new data type called CGImage from the output image of the current filter.
if let cgimg = context.createCGImage(image, from: image.extent) {
// 转换成UIImageView需要的UIImage
let processedImage = UIImage(cgImage: cgimg)
// 放入UIImageView
imageView.image = processedImage
}

UIImageWriteToSavedPhotosAlbum()

向用户的相册写入图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 假设有一个UIButton的action是下面的func
@IBAction func save(_ sender: Any) {
guard let image = imageView.image else { return }

UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)
}

@objc func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
if let error = error {
// we got back an error!
let ac = UIAlertController(title: "Save error", message: error.localizedDescription, preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
present(ac, animated: true)
} else {
let ac = UIAlertController(title: "Saved!", message: "Your altered image has been saved to your photos.", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
present(ac, animated: true)
}
}

UIView.animate

一般用法:

1
UIView.animate(withDuration: <#T##TimeInterval#>, delay: <#T##TimeInterval#>, animations: <#T##() -> Void#>)

更丝滑的用法:(使用到了usingSpringWithDamping / initialSpringVelocity,是为了增加润滑度的)

1
UIView.animate(withDuration: <#T##TimeInterval#>, delay: <#T##TimeInterval#>, usingSpringWithDamping: <#T##CGFloat#>, initialSpringVelocity: <#T##CGFloat#>, animations: <#T##() -> Void#>)

案例:

1
2
3
4
5
6
7
8
9
10
11
12
UIView.animate(withDuration: 1, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: [], animations: {
self.imageView.alpha = 0.1
self.imageView.backgroundColor = UIColor.green
// 后续可以设置案件让其还原
// 比如
// self.imageView.alpha = 1.0
// self.imageView.backgroundColor = UIColor.clear

// 这里补充一点 UIColor.clear 的定义
// open class var clear: UIColor { get } // 0.0 white, 0.0 alpha
// 所以上面代码会让背景色变白并且透明度为0即不可见
}

UIView.animate 的 completed 的trailing closure参数

下面的代码可以在animate的动画结束之后再执行一段代码。

1
2
3
4
5
6
7
8
9
10
11
UIView.animate(withDuration: 1, delay: 0, options: [], animations: {
switch self.currentAnimation {
case 0:
break

default:
break
}
}) { finished in
sender.isHidden = false
}

UIView.animate 的思考

上面代码中,在UIView.animate中变化的是self.imageView.alpha。
同样的情景,但换做是一个UIButton,”_ sender: UIButton”是其传来的参数,你使用sender.isHidden = true, 是不会有过渡变化的,而你使用sender.alpha = 0,就会带来过渡的变化。后者很好理解,因为alpha的取值是从0-1.0之前,但为什么sender.isHidden从false到true,也是让图像从有到无,为何就没有用呢? (还记得在SwfitUI中,withAnimation中好像是可以放入true或false的改变的,也会有动画效果的)
教程中是这么讲的,”because isHidden is either true or false, it has no animatable values between.”
上面代码还用到了backgroundColor,是不是颜色从一种到另一种,其中是带有值的过渡的,所以也是可行的。

使用需要是weak等去除强引用?没必要!

For the animations closure we don’t need to use [weak self] because there’s no risk of strong reference cycles here – the closures passed to animate(withDuration:) method will be used once then thrown away.

UIViewPropertyAnimator – 类似于withAnimation

不同于UIView.animate,因为感觉它只能针对这个UIView,而UIViewPropertyAnimator使用方式类似于withAnimation
看一下例子:

1
2
3
4
5
6
7
8
9
10
11
let redBox = UIView(frame: CGRect(x: -64, y: 0, width: 128, height: 128))
redBox.translatesAutoresizingMaskIntoConstraints = false
redBox.backgroundColor = UIColor.red
redBox.center.y = view.center.y
view.addSubview(redBox)

animator = UIViewPropertyAnimator(duration: 2, curve: .easeInOut) { [unowned self, redBox] in
redBox.center.x = self.view.frame.width
}

animator.startAnimation()

UIViewPropertyAnimator

UIViewPropertyAnimator的addCompletion方法

再增加点特效,存在过去和回来以及旋转的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let redBox = UIView(frame: CGRect(x: -64, y: 0, width: 128, height: 128))
redBox.translatesAutoresizingMaskIntoConstraints = false
redBox.backgroundColor = UIColor.red
redBox.center.y = view.center.y
view.addSubview(redBox)

animator = UIViewPropertyAnimator(duration: 2, curve: .easeInOut) { [unowned self, redBox] in
redBox.center.x = self.view.frame.width
redBox.transform = CGAffineTransform(rotationAngle: CGFloat.pi).scaledBy(x: 0.001, y: 0.001)
}

animator.addCompletion { _ in
let secondAnimator = UIViewPropertyAnimator(duration: 2, curve: .easeInOut) {
redBox.center.x = 0
redBox.transform = CGAffineTransform(rotationAngle: CGFloat.pi * 2).scaledBy(x: 1.0, y: 1.0)
}
secondAnimator.startAnimation()
}

animator.startAnimation()

UIViewPropertyAnimator

UIViewPropertyAnimator的fractionComplete属性 – 配合UISlider的使用

UIViewPropertyAnimator的ffractionComplete属性是个CGFloat
我们来使用UISlider来决定该动画的完成度(个人理解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 定义一个UISlider
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(slider)

slider.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -400).isActive = true
slider.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true


// 定义该UISlider对应的划动事件
slider.addTarget(self, action: #selector(sliderChanged), for: .valueChanged)

// 定义一个box
let redBox = UIView(frame: CGRect(x: -64, y: 0, width: 128, height: 128))
redBox.translatesAutoresizingMaskIntoConstraints = false
redBox.backgroundColor = UIColor.red
redBox.center.y = view.center.y
view.addSubview(redBox)

// 使用UIViewPropertyAnimator来定义动画
animator = UIViewPropertyAnimator(duration: 2, curve: .easeInOut) { [unowned self, redBox] in
redBox.center.x = self.view.frame.width
redBox.transform = CGAffineTransform(rotationAngle: CGFloat.pi).scaledBy(x: 0.001, y: 0.001)
}

// 以及该对应划动事件的具体执行方法
@objc func sliderChanged(_ sender: UISlider) {
animator.fractionComplete = CGFloat(sender.value)
}

当滑动UISlider的时候会出现的效果:
UIViewPropertyAnimator

UIViewPropertyAnimator的stopAnimation(withoutFinishing: Bool)方法

即中止动画。

实例–点击卡牌后产生翻转动画的效果

CardFlipping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class ViewController: UIViewController {
@IBOutlet var imageView: UIImageView!

var flipAnimator: UIViewPropertyAnimator?

override func viewDidLoad() {
super.viewDidLoad()

imageView.image = UIImage(named: "1Characters_back")

view.addSubview((imageView))

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageViewTap))

imageView.isUserInteractionEnabled = true // 允许用户交互
imageView.addGestureRecognizer(tapGesture)
}

@objc func imageViewTap(_ sender: UITapGestureRecognizer) {

flipAnimator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self, imageView] in
// 这里防止用户反复点击产生反效果,而取消用户交互的功能
self.imageView.isUserInteractionEnabled = false
UIView.transition(with: self.imageView, duration: 1, options: [.transitionFlipFromRight]) {
self.imageView.image = UIImage(named: "NinjaAdventure")
} // UIView 就是 with参数中设置的self.imageView,而不是view,这点非常重要!
}

flipAnimator?.startAnimation()
}
}

这里使用到了UIView.transition功能,具体用法有:

  • UIView.transition(with: UIView, duration: TimeInterval, options: UIView.AnimationOptions,animations: (() -> Void)?))
  • UIView.transition(from: UIView, to: UIView, duration: Timeinterval)
  • UIView.transition(from: UIView, to: UIView, duration: TimeInterval, options: UIView.AnimationOptions)
    三种使用的方法,稍有不同。第一种在上例中已经展现。后两种在一些情况下可以使用,因为不能像第一种一样设置with参数,所以不能特别指定是哪个view有动画,只能默认是整个view;如果在UICollectionView中的cell,cell中有一个占据整个篇幅的ImageView,那是可以使用后两种方法的。

CGAffineTransform

任何的UIView都可以使用CGAffineTransform。

一般用法案例:

1
2
3
4
5
6
7
8
9
10
11
12
UIView.animate(withDuration: 1, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: [], animations: {
switch self.currentAnimation {
case 0:
self.imageView.transform = CGAffineTransform(scaleX: 2, y: 2)

case 1:
self.imageView.transform = .identity

default:
break
}
}

这里有两个知识点:

  1. UIView.transform 可更改外形、大小、角度等;
  2. UIView.transform = .identity 即可恢复原状。

CGAffineTransform还有的用法:

1
2
3
4
5
6
// 位移
self.imageView.transform = CGAffineTransform(translationX: -256, y: -256)

// 旋转
self.imageView.transform = CGAffineTransform(rotationAngle: CGFloat.pi * 3.0 / 2.0)
// 但要记住,它比较懒,怎么能最快到终点,就会选这个捷径,实际这个只逆时针转了90度

CGAffineTransform.identity

CGAffineTransform也有identity,作用是恢复到transform之前的状态。

MapKit

MKMapView及其的delegate

在一个UIView中建立了一个MKMapView后,需要让UIView作为这个MKMapView的delegate的话,需要在storyboard中ctrl-drag这个MKMapView到UIView上面,就会出现让你选择delegate的情况,选择好了以后就可以了。

原文是这样写的:
Using the assistant editor, please create an outlet for your map view called mapView. You should also set your view controller to be the delegate of the map view by Ctrl-dragging from the map view to the orange and white view controller button just above the layout area. You will also need to add import MapKit to ViewController.swift so it understands what MKMapView is.
Note: If you don’t set the map’s delegate, the rest of this project won’t work too well.

MKMapView的用例:

在storyboard上生成一个map kit view。

mapType 更改显示地图的样式

1
2
3
4
// @IBOutlet var mapView: MKMapView!

// 显示卫星地图,还有其他的一些选择
mapView.mapType = .satellite

MKAnnotation – protocol

在地图上显示图钉。
遵循MKAnnotation协议的必须是class,不能是struct!!!
遵循MKAnnotation协议的情况下,必须要有一个coordinate: CLLocationCoordinate2D的声明,如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Capital: NSObject, MKAnnotation {
var title: String?
// 需要实现一个CLLocationCoordinate2D
var coordinate: CLLocationCoordinate2D
var info: String

init(title: String, coordinate: CLLocationCoordinate2D, info: String) {
self.title = title
self.coordinate = coordinate
self.info = info
}
}

在viewDidLoad()中可以生成这个annotation:

1
2
let london = Capital(title: "London", coordinate: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), info: "Home to the 2012 Summer Olympics.")
let oslo = Capital(title: "Oslo", coordinate: CLLocationCoordinate2D(latitude: 59.95, longitude: 10.75), info: "Founded over a thousand years ago.")

随后在地图上显示这些annotation:

1
2
3
4
5
6
7
8
9
10
// @IBOutlet var mapView: MKMapView!

// 一起添加:
// mapView.addAnnotations(<#T##annotations: [MKAnnotation]##[MKAnnotation]#>)
mapView.addAnnotations([london, oslo])

// 或者一个一个添加:
// mapView.addAnnotation(<#T##annotation: MKAnnotation##MKAnnotation#>)
mapView.addAnnotation(london)
mapView.addAnnotation(oslo)

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {

https://www.hackingwithswift.com/read/16/3/annotations-and-accessory-views-mkpinannotationview

Every time the map needs to show an annotation, it calls a viewFor method on its delegate. We don’t implement that method right now, so the default red pin is used with nothing special.

但首先要让View遵循 MKPinAnnotationView protocol :

1
class ViewController: UIViewController, MKMapViewDelegate {

接下来就是完成func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// 1.If the annotation isn't from a capital city, it must return nil so iOS uses a default view.
guard annotation is Capital else { return nil }

// 2.Define a reuse identifier. This is a string that will be used to ensure we reuse annotation views as much as possible.
let identifier = "Capital"

// 3.Try to dequeue an annotation view from the map view's pool of unused views.
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)

if annotationView == nil {
// 4.If it isn't able to find a reusable view, create a new one using (MKPinAnnotationView is deprecated) and sets its canShowCallout property to true. This triggers the popup with the city name.
annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView?.canShowCallout = true

// 5. Create a new UIButton using the built-in .detailDisclosure type. This is a small blue "i" symbol with a circle around it.
// we don't need to use addTarget() to add an action to the button, because you'll automatically be told by the map view using a calloutAccessoryControlTapped method.
let btn = UIButton(type: .detailDisclosure)
annotationView?.rightCalloutAccessoryView = btn
} else {
// 6. If it can reuse a view, update that view to use a different annotation.
annotationView?.annotation = annotation
}

return annotationView
}

接下来就是每个annotation被点击后实现方法了:

1
2
3
4
5
6
7
8
9
10
// calloutAccessoryControlTapped method can make the tapped button know to call it.
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
guard let capital = view.annotation as? Capital else { return }
let placeName = capital.title
let placeInfo = capital.info

let ac = UIAlertController(title: placeName, message: placeInfo, preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
present(ac, animated: true)
}

实现效果是这样的:
annotationButtonTapped

Timer

Timer.scheduledTimer(timeInterval: <#T##TimeInterval#>, target: <#T##Any#>, selector: <#T##Selector#>, userInfo: <#T##Any?#>, repeats: <#T##Bool#>)

多少个时间单位内(timeInterval), 执行什么代码(selector), 是否重复执行(repeats)。
After creating a Timer, it should be created using Timer.scheduledTimer() method to activate it.

1
Timer.scheduledTimer(timeInterval: 0.35, target: self, selector: #selector(createEnemy), userInfo: nil, repeats: true)

invalidate() – Timer.scheduledTimer的停止

让一个计时器停止:

1
2
3
4
5
6
// var gameTimer: Timer?

// gameTimer = Timer.scheduledTimer(timeInterval: 0.35, target: self, selector: #selector(createEnemy), userInfo: nil, repeats: true)

gameTimer?.invalidate()
gameTimer = nil

debug

print

1
2
3
4
5
6
7
8
9
10
print("I'm inside the viewDidLoad() method!")

print(1, 2, 3, 4, 5)
// "1 2 3 4 5\n"

print(1, 2, 3, 4, 5, separator: "-")
// "1-2-3-4-5\n"

print("Some message", terminator: "")
// "Some message"

assert

一般用法

两个参数,前面的条件不满足或是false,则显示预设的错误信息,并让测试时的程序崩溃。

1
assert(1 == 1, "Maths failure!")

breakpoints

  • Fn+F6 – 到breakpoint处时,一行一行地执行
  • Ctrl+Cmd+Y – 执行到下一个breakpoint

给Breakpoint加上条件

例如在循环中每十次进行一次breakpoint:
对着设定的breakpoint右键,跳出菜单中选择”edit breakpoint”,再跳出的菜单:
editBreakpoint
在Condition中输入”i % 10 == 0”即可。

Exception Breakpoint

Exception Breakpoint will be automatically triggered when an exception is thrown. Exceptions are errors that aren’t handled, and will cause your code to crash. With breakpoints, you can say “pause execution as soon as an exception is thrown,” so that you can examine your program state and see what the problem is.

Cmd+8 调出“Show the Breakpoint navigator”,左下角按”+”按钮,调出如下菜单,选择”Exception Breakpoint”:
Exception Breakpoint
进行必要的设置:
conceptionBreakpointDetail
The next time your code hits a fatal problem, the exception breakpoint will trigger and you can take action.

下面的图就是当出现错误的情况时,会出现 NSException:
NSException

lldb窗口

LLDB is the default debugger in Xcode on macOS and supports debugging C, Objective-C and C++ on the desktop and iOS devices and simulator.

在运行project时,可以在菜单栏 View > Debug Area > Activate Console, 可以看到下面的lldb窗口:
lldb

命令行:
p – 同print,比如要打印变量i,”p i”即可。

View Debugging

在运行项目后,在代码页面, 菜单栏的 Debug -> View Debugging -> Capture View Hierarchy 。
如下图:

ViewDebugging

Here’s the clever part: if you click and drag inside the hierarchy display, you’ll see you’re actually viewing a 3D representation of your view, which means you can look behind the layers to see what else is there. The hierarchy automatically puts some depth between each of its views, so they appear to pop off the canvas as you rotate them.

This debug mode is perfect for times when you know you’ve placed your view but for some reason can’t see it – often you’ll find the view is behind something else by accident.

还可以使用下图中打红圈的快捷键打开 View Debugging:
ViewDebuggingButton

CADisplayLink

https://www.hackingwithswift.com/example-code/system/how-to-synchronize-code-to-drawing-using-cadisplaylink
https://www.jianshu.com/p/5e8d783d377a

CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
我们在应用中创建一个新的 CADisplayLink 对象,把它添加到一个runloop中,并给它提供一个 target 和 selector 在屏幕刷新的时候调用。
CADisplayLink比NSTimer好的地方是,后者会有延迟,而CADisplayLink的好处是,我们不需要在格外关心屏幕的刷新频率了,因为它本身就是跟屏幕刷新同步的。

1
2
3
let displayLink = CADisplayLink(target: self, selector: #selector(createEnemy))
displayLink.preferredFrameRateRange = CAFrameRateRange(minimum: 1, maximum: 2)
displayLink.add(to: .current, forMode: .common)

代码在Project17上跑的通,但有些疑问,为什么CAFrameRateRange(minimum: 0.5, maximum: 0.5)的时候就会疯狂执行,而数值大于1以后,就会正常一些。

这个模块,以后要做动画游戏的时候需要好好研究一下。
笔记待后续补充。

info.plist

plist是property list的缩写,它包含了app、插件(extensions)的元数据(metadata),这些是关于:what language is it, what version number is it, and so on。

extension

safari extensions

https://www.hackingwithswift.com/100 Day67-69

p.s.(2023-1-28)这章节的内容实在是太乱了,新东西太多,后续还要再理一下,如果确实要写extension的话,最好结合上述网页上的实例再看。

safari extensions必须要在safari的action menu中才能启动,类似于:
safariExtension
这个extension是植入到Safari中的,它需要safari才会有用,而不是像其他程序一样可以独立运行。

生成一个在原有项目下的extension:
Go to the File menu and choose New > Target. When you’re asked to choose a template, select iOS > Application Extension > Action Extension, then click Next. For the name just call it Extension(or other name whatever you want), make sure Action Type is set to “Presents User Interface”, then click Finish.
这样就在一个project中建立了一个extension,而我们给其取名”Extension”,最后我们在sfari的action menu中可以看到一个Extension的可点击项,上面图像中可以看到。

safari中就可以运行extension,安全性在哪里?
实际上,你的extension与safari是不会通信的,因为系统安全原因,而在这其中,iOS起到了一个中间桥梁的作用,iOS在其中安全地传递数据。

extensionContext:
母程序(parent app)就比如说你在safari里做了一个插件,那么这个safari就是母程序,插件就是extension。
当我们的插件被建立后,extensionContext是用来让我们控制与母程序交互的东西。

inputItems:
extensionContext?.inputItems是一个存有数据的数组,该数据是母程序发送给插件使用的。我们一般只关心第一个item,所以会写成extensionContext?.inputItems.first。

NSItemProvider:
母程序发送给我们的数据都被包装成了一个个NSItemProvider。我们的程序把所有的数组数据中的第一个取出,而这个被取出的数据应当是一个NSItemProvider。

1
if let inputItem = extensionContext?.inputItems.first as? NSExtensionItem {

接下来我们把inputItem中的所有attachments,只取出第一个attachment,代码就是:

1
if let itemProvider = inputItem.attachments?.first {

合起来的代码就是:

1
2
3
4
5
6
if let inputItem = extensionContext?.inputItems.first as? NSExtensionItem {
if let itemProvider = inputItem.attachments?.first {

}
}
}

loadItem(forTypeIdentifier: )
loadItem(forTypeIdentifier: )是要求数据提供者真正地去提供这个item给我们。因为它使用到了一个trailing closure,所以执行的是异步程序,这个方法会持续执行,这是因为有时候这个item提供者可能忙于载入或者发送数据。在这个trailing closure中,我们需要使用到 [weak self]去避免强引用,此外,我们还需要接受两个参数,第一个是item提供者给我们的一个dictionary,另一个是发生的任何error。

1
2
3
itemProvider.loadItem(forTypeIdentifier: kUTTypePropertyList as String) {[weak self] (dict, error) in
// do stuff!
}

先要说一下的是,在Action.js中的代码中有一段:

1
2
3
4
5
// run及其代码的意思是:
// 告知iOS这个JavaScript已经预处理完毕,把这个dictionary(里面的key分别是"URL"和"title"以及对应的值)给extension吧
run: function(parameters) {
parameters.completionFunction({ "URL": document.URL, "title": document.title });
}

loadItem的trailing closure接收到这个dictionary并进行处理,这也是其是异步方法的愿意。
// do stuff中的代码依次是:

1
guard let itemDictionary = dict as? NSDictionary else { return }

NSDictionary是一个对我们来说是新的数据类型,但它来自旧时代的iOS代码,就把它看作是一个Swift中的dictionary吧。现在更多使用的是modern Swift dictionaries,而非NSDictionary。但这里NSDictionary是与插件配合使用的。
打印了一下上面的itemDictionary:
NSDictionary

1
guard let javaScriptValues = itemDictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return }

这行代码中的key为NSExtensionJavaScriptPreprocessingResultsKey,这是从JavaScript中传递过来的数据的key。

如果你打印上面javaScriptValues的值,你会看到类似:

1
2
3
4
{
URL = "https://www.apple.com/retail/code/";
title = "Apple Retail Store - Hour of Code Workshop";
}

接下来就可以设置插件中的这两个后续我们要使用的属性了:

1
2
self?.pageTitle = javaScriptValues["title"] as? String ?? ""
self?.pageURL = javaScriptValues["URL"] as? String ?? ""

完成代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if let inputItem = extensionContext?.inputItems.first as? NSExtensionItem {

if let itemProvider = inputItem.attachments?.first {
itemProvider.loadItem(forTypeIdentifier: kUTTypePropertyList as String) {[weak self] (dict, error) in
guard let itemDictionary = dict as? NSDictionary else { return }
guard let javaScriptValues = itemDictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return }
self?.pageTitle = javaScriptValues["title"] as? String ?? ""
self?.pageURL = javaScriptValues["URL"] as? String ?? ""

DispatchQueue.main.async {
self?.title = self?.pageTitle
}
}
}
}

// do stuff中的代码是经过简单编写的,但建议自己去建立一个extension,看一下默认模版中是怎么遍历所有的items和providers,并最终找到第一张图片的。

info.plist内的设置:
先看一下设置后的info.plist内部的构成:
NSExtensionOfInfoPlist
有一个Information Property List。因为我们这里是一个插件,所以该list里面有一个NSExtension。
这个NSExtension里面一般有三样东西:
NSExtensionAttributes, NSExtensionMainStoryboard and NSExtensionPointIdentifier.
这里我们只关心会改变我们的插件行为的NSExtensionAttributes。
我们设置的目的是:
一是设置接收的是什么数据,二是设置后期要执行的语言类型及具体文件名。
在NSExtensionAttributes下面有一个NSExtensionActivationRule,Type是”String”,值是”TRUEPREDICATE”,把它修改Type为Dictionary,在里面“+”一个item,名字为”NSExtensionActivationSupportsWebPageWithMaxCount”,Type为String,值设为1。把这个值加到新设的Dictionary里面,是因为我们只想要收到网页数据(web pages),而对images或其他数据类型不感兴趣。
接下来选择NSExtensionAttributes,在里面添加名为”NSExtensionJavaScriptPreprocessingFile”,Type为”String”,值为”Action”。这样设置,就是当插件被called的时候,我们需要运行JavaScript的预处理文件,文件名为Action.js。这样需要注意,这里你设置的值是”Action”而不是”Action.js”,因为iOS会帮我们把.js加上去的。

既然在info.plist中已经设置了要执行的”Action.js”文件,那么我们就要创建它:
Right-click on your extension’s Info.plist file and choose New File. When you’re asked what template you want, choose iOS > Other > Empty, then name it Action.js, and put this text into it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Action = function() {};

Action.prototype = {

// run及其代码的意思是:
// 告知iOS这个JavaScript已经预处理完毕,把这个dictionary(里面的key分别是"URL"和"title"以及对应的值)给extension吧
run: function(parameters) {
parameters.completionFunction({ "URL": document.URL, "title": document.title });
},

finalize: function(parameters) {
var customJavaScript = parameters["customJavaScript"];
eval(customJavaScript);
}

};

var ExtensionPreprocessingJS = new Action

There are two functions: run() and finalize(). The first is called before your extension is run, and the other is called after.
Apple expects the code to be exactly like this, so you shouldn’t change it other than to fill in the run() and finalize() functions.

设置到这里,我们在Project Navigator中可以看到的extenison是这样的:
ExtensionFromProjectNavigator
此外,在Build Phases的Compile Sources和Copy Bundle Resources中,应该是如上图这样,Action.js是在Copy Bundle Resources中,而不是在Compile Sources中。

最终的Action.js是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var Action = function() {}

Action.prototype = {

run: function(parameters) {
// tell iOS the JavaScript has finished preprocessing, and give this data dictionary to the extension.
// The data that is being sent has the keys "URL" and "title", with the values being the page URL and page title.
parameters.completionFunction({ "URL": document.URL, "title": document.title });

},

finalize: function(parameters) {
var customJavaScript = parameters["customJavaScript"];
eval(customJavaScript);
}

};

var ExtensionPreprocessingJS = new Action

在MainInterface.storyboard上建立一个UITextNode,让其auto layout,随后ctrl+drag让其在ActionViewController上对应属性:

1
@IBOutlet var script: UITextView!

在extension界面的右上角加一个执行的按钮:(注意extension界面只有在被调用到插件功能的时候才会启用)

1
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done))

此时写下要执行的objc的方法:

1
2
3
4
5
6
7
8
9
10
11
12
@objc func done() {
let item = NSExtensionItem()
// 注意看在Action.js的finalize方法中定义到了
// var customJavaScript = parameters["customJavaScript"];
let argument: NSDictionary = ["customJavaScript": script.text]
let webDictionary: NSDictionary = [NSExtensionJavaScriptFinalizeArgumentKey: argument]
let customJavaScript = NSItemProvider(item: webDictionary, typeIdentifier: kUTTypePropertyList as String)
item.attachments = [customJavaScript]

// completeRequest(returningItems:)的调用会造成插件被关闭,并返回母程序,而且可以传回母程序任何我们定义的items。
extensionContext?.completeRequest(returningItems: [item])
}

可以观察一下:
1、之前我们从safari接收到的是一个含有NSExtensionItem元素的列表,而这里extensionContext?.completeRequest(returningItems: [item]),最终传回safari的也是一个含有NSExtensionItem元素的列表。
2、我们朝NSDictionary传递的key是NSExtensionJavaScriptFinalizeArgumentKey,对应的是Action.js的finalize方法,而之前我们一开始从safari取得的key是NSExtensionJavaScriptPreprocessingResultsKey,对应的是Action.js的run方法。
这样就能说得通了。I realize that seems like far more effort than it ought to be, but it’s really just the reverse of what we are doing inside viewDidLoad().

NotificationCenter

在我们的scenes背后,当有”键盘事件”、”应用进入后台”以及一些其他的事件,iOS会持续地向我们发出notification。我们可以对一些特定的notification加入observer进行回应,也可以进行数据的传递。

Fixing the keyboard

https://www.hackingwithswift.com/read/19/7/fixing-the-keyboard-notificationcenter

iOS上会有一个keyboard的问题:
比如我们现在有一个可以输入多行的UITextView:
KeyboardNotificationCenter
我们可以看出这个UITextView占据屏幕的大小。
如果我们执行,并调出键盘来一行行的打字,当打的字即将超过键盘所在的位置的时候,会发生什么事情呢?
KeyboardNotificationCenter
为什么会发生这样的事情?
因为当你调出keyboard的时候,这个textView的可使用面积没有自动去调整,仍旧那么大,就会出现打的字出现下键盘下方并被遮盖的情况。

keyboardWillHideNotification / keyboardWillChangeFrameNotification

当键盘隐藏不用的时候,系统会发出keyboardWillHideNotification。
当键盘隐藏不用、键盘状态发生改变(比如出现,或者屏幕从portrait专程landscape等等),系统都会发出keyboardWillChangeFrameNotification。
看上去keyboardWillChangeFrameNotification已经涵盖了keyboardWillHideNotification,但有时候一些奇怪的场景还是需要用到keyboardWillHideNotification的(现在还没有碰到过)。

添加对这两个键盘事件的观察:

1
2
3
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)

对应的adjustForKeyboard方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// @IBOutlet var textView: UITextView!

@objc func adjustForKeyboard(notificaiton: Notification) {
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }

let keyboardScreenEndFrame = keyboardValue.cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)

// 假如keyboard隐藏了,则textView的contentInset正常,即四周不做任何调整
if notification.name == UIResponder.keyboardWillHideNotification {
// textView.contentInset可以理解为是textView的padding,感觉不是margin
textView.contentInset = .zero
} else {
// 此时一定是keyboard在使用的情况
textView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
// 这里bottom为何是keyboardViewEndFrame.height - view.safeAreaInsets?
// 因为经答应两个数值可以知道,keyboardViewEndFrame.height是从屏幕最下方开始的,包括了view.safeAreaInsets.bottom,
// 而我们的textView开始设置的时候是不包含safeArea的,所以要减掉safeArea的范围。
}
}

再对scroll indicator进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
@objc func adjustForKeyboard() {
// 省略上面的代码

/*
下面这三行代码是教程中加上去的,但操作下来感觉没什么用啊
*/
// Scroll indicator insets control how big the scroll bars are relative to their view.
textView.scrollIndicatorInsets = textView.contentInset
// textView.selectedRange应该理解为是可选择或可使用的范围
let selectedRange = textView.selectedRange
// 设置textView可滚动的范围
textView.scrollRangeToVisible(selectedRange)

NotificationCenter的post的传输自制的提醒

1
2
let notificationCenter = NotificationCenter.default
notificationCenter.post(name: Notification.Name("UserLoggedIn"), object: nil)

If no other part of your app has subscribed to receive that notification, nothing will happen. But you can make any other objects subscribe to that notification – it could be one thing, or ten things, it doesn’t matter. This is the essence of loose coupling: you’re transmitting the event to everyone, with no direct knowledge of who your receivers are.

NotificationCenter的UIApplication.willResignActiveNotification事件

当整个程序不再是active状态(即将进入background)之前会发生的事件。

1
2
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(saveSecretMessage), name: UIApplication.willResignActiveNotification, object: nil)

UserNotifications – UN

看下来,UserNotifications就是在程序中设置好,取得用户授权后,在固定时间点或此后的一段时间后,给用户的手机系统发送提醒信息的一个模块。

If you want to use the UserNotifications framework, you should import it:

1
import UserNotifications

UNUserNotificationCenter.requestAuthorization – 获取用户授权

给用户发送提醒信息的权限,需要用户授权:

1
2
3
4
5
6
7
8
9
10
11
let center = UNUserNotificationCenter.current()

// options表示同意授权的权限包含: 提醒/app上标/声音 这三项
// granted是一个Boolean
center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
if granted {

} else {

}
}

跳出来的是:
requestAuthorization

UNNotificationRequest(identifier:, content:, trigger:)

下面的代码设置的是TimeInterval的通知方式(UNTimeIntervalNotificationTrigger),5秒钟后,给系统发送通知信息,告知content中的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let center = UNUserNotificationCenter.current()

// UNNotificationRequest的content部分
let content = UNMutableNotificationContent()
content.title = "Late wake up call"
content.body = "The early bird catches the worm, but the second mouse gets the cheese."
// -- to attach custom actions
content.categoryIdentifier = "alarm"
// -- to attach custom data to the notification
content.userInfo = ["customData": "fizzbuzz"]
// -- to specify a sound
content.sound = UNNotificationSound.default

// UNNotificationRequest的trigger部分
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)


let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
center.add(request)

也可以设置成Calendar的通知方式,每天10:30发送: (UNCalendarNotificationTrigger)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let center = UNUserNotificationCenter.current()

// cancel future scheduled notifications, to start over
// 若再次启动的话,需要删除之前悬停着的提醒设定
center.removeAllPendingNotificationRequests()

let content = UNMutableNotificationContent()
content.title = "Late wake up call"
content.body = "The early bird catches the worm, but the second mouse gets the cheese."
// content.categoryIdentifier指你可以在notification中加入自定义的action,
// 下面会用到的UNNotificationCategory(identifier:, actions: [], intentIdentifiers: [])的identifier必须对应指定是"alarm"
content.categoryIdentifier = "alarm"
// content.userInfo指你可以在notification中加入自定义的数据,
content.userInfo = ["customData": "fizzbuzz"]
content.sound = UNNotificationSound.default

var dateComponents = DateComponents()
dateComponents.hour = 10
dateComponents.minute = 30
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)

// 这里的identifier的指定(虽然在这个例子中可有可无),但它可以让你在之后 更新(update) 或 移除(remove) 提醒。
// 你也可以通过 center.removeAllPendingNotificationRequests() 来移除 等待的(pending) 的提醒。
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
center.add(request)

你也可以设置一个 地理围栏(geofence),它可以基于你的地理位置来发动(trigger)通知提醒。

五秒钟后发送是这样的:
UNNotificationRequest

Acting on responses – 根据用户点击选项来采取行动

https://www.hackingwithswift.com/read/21/3/acting-on-responses

UNNotificationAction UNNotificationCategory
使用UNNotificationAction和UNNotificationCategory,你可以针对跳出的提醒及用户的反应做出进一步的回应。
UNNotificationCategory对应的是上面我们设置的content.categoryIdentifier = “alarm”。
UNNotificationAction设置的是用户选择点击后作出的回应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let center = UNUserNotificationCenter.current()
// center.delegate = self 就必须让这个self符合UNUserNotificationCenterDelegate协议
center.delegate = self

// An identifier, which is a unique text string that gets sent to you when the button is tapped.
// A title, which is what user’s see in the interface.
// Options, which describe any special options that relate to the action. You can choose from .authenticationRequired, .destructive, and .foreground.
// .foreground -- The action causes the app to launch in the foreground.
// .destructive -- The action causes a destructive task.
let show = UNNotificationAction(identifier: "show", title: "Tell me more...", options: .foreground)
let show2 = UNNotificationAction(identifier: "show2", title: "Tell me another...", options: .authenticationRequired)
// 此处就必须要对应上面讲到的 content.categoryIdentifier = "alarm"
// 此处的intentIdentifiers--this is used to connect your notifications to intents, if you have created any.
let category = UNNotificationCategory(identifier: "alarm", actions: [show, show2], intentIdentifiers: [])

center.setNotificationCategories([category])

// 下面的代码是上面例子的重复,但要生成notification是必须的,所以就重复写了一遍
let content = UNMutableNotificationContent()
content.title = "Late wake up call"
content.body = "The early bird catches the worm, but the second mouse gets the cheese."
content.categoryIdentifier = "alarm"
content.userInfo = ["customData": "fizzbuzz"]
content.sound = UNNotificationSound.default

var dateComponents = DateComponents()
dateComponents.hour = 10
dateComponents.minute = 30
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)


let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
center.add(request)

效果是这样的:
UNNotificationRequest_2

UNUserNotificationCenter的getNotificationSettings – 查看是否已获得用户的允许发送提醒

感觉下面这样写比较好,查看是否获得用户授权发提醒,若未获授权,则请求授权;若已授权,则进一步设置提醒的内容和方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func manageNotifications() {
let notificationCenter = UNUserNotificationCenter.current()

notificationCenter.getNotificationSettings { [weak self] (settings) in

// user has not made a choice yet regarding accepting notifications
if settings.authorizationStatus == .notDetermined {
// use this opportunity to explain why it could be useful
let ac = UIAlertController(title: "Daily reminder", message: "Allow notifications to be reminded daily of playing Guess the Flag", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "Next", style: .default) { _ in
self?.requestNotificationsAuthorization()
})
self?.present(ac, animated: true)
return
}

// user already has accepted notifications
if settings.authorizationStatus == .authorized {
self?.scheduleNotifications()
}
}
}

func requestNotificationsAuthorization() {
let notificationCenter = UNUserNotificationCenter.current()

notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in
if granted {
self?.scheduleNotifications()
}
else {
// explain how notifications can be activated
let ac = UIAlertController(title: "Notifications", message: "Your choice has been saved.\nShould you change your mind, head to \"Settings -> Project21-Challenge3 -> Notifications\" to update your preferences.", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
self?.present(ac, animated: true)
}
}
}

didReceive – 处理上面设置的content.userInfo = [“customData”: “fizzbuzz”]等传输数据

UNUserNotificationCenterDelegate协议定义了userNotificationCenter方法,可以接收一个@escaping,来等待并处理传输的数据:
(这不是必须要定义的方法,只是在需要处理传输的数据的时候才有必要)

1
userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler

我们可以这样定义该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo

if let customData = userInfo["customData"] as? String {
print("Custom data received: \(customData)")

switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// the user swiped to unlock
print("Default identifier")
case "show":
// 针对我们之前的三行代码:
// let show = UNNotificationAction(identifier: "show", title: "Tell me more...", options: .foreground)
// let category = UNNotificationCategory(identifier: "alarm", actions: [show], intentIdentifiers: [])
// center.setNotificationCategories([category])
print("Show more information...")
// 你也可以再执行一遍scheduleNotifications()来设置新的提醒
default:
break
}
}

很有趣的是,它只响应center.setNotificationCategories,就是你只有点了”Tell me more…”或者”Tell me another…”按钮才会执行该方法,很奇怪,难道它只针对UNNotificationCategory,为啥?
center.setNotificationCategories的参数是[category],而category指向的actions是[show, show2, …],所以它只针对action来回应。

AVFoundation

使用AVFoudation模块的AVPlayer播放声音文件

在使用SpriteKit模块使用中,为何不使用其本身就有的SKAction.playSoundFileNamed()?
而是要去使用AVAudioPlayer(contentsOf: URL)?
教材中称是,AVAudioPlayer可以在你需要的时候,随时停止声音的播放。

基本使用方法:

1
2
3
4
5
6
if let path = Bundle.main.url(forResource: "sliceBombFuse", withExtension: "caf") {
if let sound = try? AVAudioPlayer(contentsOf: path) {
bombSoundEffect = sound
sound.play()
}
}

你可以使用sound.stop()来停止播放。

AVAudioSession && AVAudioRecorder

the iOS way of recording audio from the microphone.
Recording audio in iOS uses two classes: AVAudioSession and AVAudioRecorder.
AVAudioSession is there to enable and track sound recording as a whole, and AVAudioRecorder is there to track one individual recording. That is, the session is the bit that ensures we are able to record, the recorder is the bit that actual pulls data from the microphone and writes it to disk.

具体的案例见下方网址:

https://www.hackingwithswift.com/read/33/2/recording-from-the-microphone-with-avaudiorecorder

audioRecorderDidFinishRecording方法 – AVAudioRecorder的默认结束方法

AVAudioRecorder不管成功与否,到结束的时候都会自动调用一个audioRecorderDidFinishRecording方法。

1
2
3
4
5
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
if !flag {
// 如果不成功的处理代码
}
}

switch

@unknown default

There’s one final case in there to handle any unknown cases that crop up in the future. While we could have made one of the other cases handle that using a regular default case, in this project none of them really make sense for whatever might occur in the future so I’ve added a dedicated @unknown default case to handle future cases.

Multipeer Connectivity

AirDrop功能强大,但它与app的整合不够紧密。幸运的是,从iOS7开始引入了一个新的框架,就是Multipeer Connectivity。Multipeer Connectivity是建立在与AirDrop相同技术上的一个框架。它传输范围比蓝牙广,不依赖网络,但需要打开wifi或蓝牙。

具体的初步实现还是挺简单的,可以参考:

https://www.hackingwithswift.com/100/83
https://www.hackingwithswift.com/100/84

#if #else #endif

https://www.hackingwithswift.com/read/26/3/tilt-to-move-cmmotionmanager
上文有提到这些用法

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
#if targetEnvironment(simulator)
if let currentTouch = lastTouchPosition {
let diff = CGPoint(x: currentTouch.x - player.position.x, y: currentTouch.y - player.position.y)
physicsWorld.gravity = CGVector(dx: diff.x / 100, dy: diff.y / 100)
}
#else
if let accelerometreData = motionManager.accelerometerData {
physicsWorld.gravity = CGVector(dx: accelerometreData.acceleration.y * -50, dy: accelerometreData.acceleration.x * 50) // ?? 这里x和y是倒的
}
#endif
}

上面的例子,自己总结下来,就是:
在模拟器上的时候,就只编译#if这部分代码,但如果是在真机的时候,就只编译#else部分的代码。

CoreMotion

All motion detection is done with an Apple framework called Core Motion.

CMMotionManager

Most of the work about motion detection is done by a class called CMMotionManager.
Using it here won’t require any special user permissions, so all we need to do is create an instance of the class and ask it to start collecting information.
We can then read from that information whenever and wherever we need to, and in this project the best place is update().
设备的移动等动作的检测,不需要得到用户的授权,无论何时何地我们都可以取得数据.
范例中,取得该数据的代码,最好的就是放在update()中:

1
2
3
4
5
var motionManager: CMMotionManager!

// 一般放在didMove(to:)
motionManager = CMMotionManager()
motionManager.startAccelerometerUpdates()
1
2
3
4
5
6
7
8
9
10
11
12
override func update(_ currentTime: TimeInterval) {
#if targetEnvironment(simulator)
if let currentTouch = lastTouchPosition {
let diff = CGPoint(x: currentTouch.x - player.position.x, y: currentTouch.y - player.position.y)
physicsWorld.gravity = CGVector(dx: diff.x / 100, dy: diff.y / 100)
}
#else
if let accelerometreData = motionManager.accelerometerData {
physicsWorld.gravity = CGVector(dx: accelerometreData.acceleration.y * -50, dy: accelerometreData.acceleration.x * 50) // ?? 这里x和y是倒的
}
#endif
}

CMMotionManager 的 startAccelerometerUpdates

如上面代码有写到。
U sing the startAccelerometerUpdates() method, which instructs Core Motion to start collecting accelerometer information we can read later.

CMMotionManager 的 accelerometerData

如上面代码有写到。
就是取得设备移动等动作的数据。

Core Graphics

Core Graphics can work on a background thread – something that UIKit can’t do – which means you can do complicated drawing without locking up your user interface.

Remember: SpriteKit’s positions things from the center and Core Graphics from the bottom left!

CGFloat && CGPoint && CGSize && CGRect 的定义

  • CGFloat – Always use this instead of Double or Float for anything to do with a UIView’s coordinate system.
  • CGPoint – Simply a struct with two CGFloats in it: x and y. eg: var point = CGPoint(x: 37.0, y: 55.2)
  • CGSize – Also a struct with two CGFloats in it: width and height . eg: var size = CGSize(width: 100.0, height: 50.0)
  • CGRect – A struct with a CGPoint and a CGSize in it.
    1
    2
    3
    4
    5
    struct CGRect {
    var origin: CGPoint
    var size: CGSize
    }
    let rect = CGRect(origin: aCGPoint, size: aCGSize) // there are other inits as well

UIGraphicsImageRenderer – class

That class name starts with “UI”, so what makes it anything to do with Core Graphics? Well, it isn’t a Core Graphics class; it’s a UIKit class, but it acts as a gateway to and from Core Graphics for UIKit-based apps like ours. You create a renderer object and start a rendering context, but everything between will be Core Graphics functions or UIKit methods that are designed to work with Core Graphics contexts.

UIGraphicsImageRenderer的image – function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))
let img = renderer.image { ctx in
let rectangle = CGRect(x: 0, y: 0, width: 512, height: 512)

ctx.cgContext.setFillColor(UIColor.red.cgColor)
ctx.cgContext.setStrokeColor(UIColor.black.cgColor)
ctx.cgContext.setLineWidth(10)

ctx.cgContext.addRect(rectangle)
ctx.cgContext.drawPath(using: .fillStroke)
}

// 之前有设置@IBOutlet var imageView: UIImageView!
imageView.image = img

上面例子中出现的 ctx.cgContext.setFillColor ctx.cgContext.setStrokeColor ctx.cgContext.setLineWidth ctx.cgContext.addRect ctx.cgContext.drawPath

  • setFillColor() sets the fill color of our context, which is the color used on the insides of the rectangle we’ll draw.
  • setStrokeColor() sets the stroke color of our context, which is the color used on the line around the edge of the rectangle we’ll draw.
  • setLineWidth() adjusts the line width that will be used to stroke our rectangle. Note that the line is drawn centered on the edge of the rectangle, so a value of 10 will draw 5 points inside the rectangle and five points outside.
  • addRect() adds a CGRect rectangle to the context’s current path to be drawn.
  • drawPath() draws the context’s current path using the state you have configured.

此外,再加一个fill():

  • fill() fill() skips the add path / draw path work and just fills the rectangle given as its parameter using whatever the current fill color is.

比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))

let img = renderer.image { ctx in
ctx.cgContext.setFillColor(UIColor.black.cgColor)

for row in 0 ..< 8 {
for col in 0 ..< 8 {
if (row + col) % 2 == 0 {
ctx.cgContext.fill(CGRect(x: col * 64, y: row * 64, width: 64, height: 64))
}
}
}
}

imageView.image = img

UIGraphicsImageRenderer
可以看到,使用ctx.cgContext.fill方法的时候,不需要像再上面的例子一样add path/draw path。


还有translateBy rotate(by:) strokePath

  • translateBy() translates (moves) the current transformation matrix. – The default behavior of rotating the CTM is to rotate from the top-left corner of our canvas.If you want to rotate from a different position you should add a translation first. – CTM is the current transformation matrix.
  • rotate(by:) rotates the current transformation matrix.
  • strokePath() strokes the path with your specified line width, which is 1 if you don’t set it explicitly.

可以画出的效果为:
drawRotatedSquares.png

代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))
let img = renderer.image { ctx in
ctx.cgContext.translateBy(x: 256, y: 256)


let rotations = 16
let amount = Double.pi / Double(rotations)

for _ in 0 ..< rotations {
ctx.cgContext.rotate(by: amount)
ctx.cgContext.addRect(CGRect(x: -128, y: -128, width: 256, height: 256))
}

ctx.cgContext.setStrokeColor(UIColor.black.cgColor)
ctx.cgContext.strokePath()
}

imageView.image = img

还有move(to:) addLine(to:)
可以呈现的效果是:
drawLines.png

代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))

let img = renderer.image { ctx in
ctx.cgContext.translateBy(x: 256, y: 256)

var first = true
var length: CGFloat = 256

for _ in 0 ..< 512 {
ctx.cgContext.rotate(by: .pi / 2)

if first {
ctx.cgContext.move(to: CGPoint(x: length, y: 50))
first = false
} else {
ctx.cgContext.addLine(to: CGPoint(x: length, y: 50))
}

length *= 0.99
}

ctx.cgContext.setStrokeColor(UIColor.black.cgColor)
ctx.cgContext.strokePath()
}

imageView.image = img

使用NSAttributedString 以及 UIImage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 1
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))

let img = renderer.image { ctx in
// 2
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center

// 3
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 36),
.paragraphStyle: paragraphStyle
]

// 4
let string = "The best-laid schemes o'\nmice an' men gang aft agley"
let attributedString = NSAttributedString(string: string, attributes: attrs)

// 5
attributedString.draw(with: CGRect(x: 32, y: 32, width: 448, height: 448), options: .usesLineFragmentOrigin, context: nil)

// 6
let mouse = UIImage(named: "mouse")
mouse?.draw(at: CGPoint(x: 300, y: 150))
}

// 6
imageView.image = img

CG-ImagesAndText

解释一下上述6个步骤:

  1. Create a renderer at the correct size.
  2. Define a paragraph style that aligns text to the center. – Paragraph style also has options for line height, indenting, and more.
  3. Create an attributes dictionary containing that paragraph style, and also a font.
  4. Wrap that attributes dictionary and a string into an instance of NSAttributedString.
  5. Load an image from the project and draw it to the context.
  6. Update the image view with the finished result.

circle
如何画出这个圆形的图形?(里面的字母忽略)
需要使用都clip()来修剪,不然就是一个长方形。

1
2
3
4
5
6
7
8
9
10
11
12
13
let original = UIImage(contentsOfFile: path)!

let renderer = UIGraphicsImageRenderer(size: original.size)

let rounded = renderer.image { ctx in
ctx.cgContext.addEllipse(in: CGRect(origin: CGPoint.zero, size: original.size))
ctx.cgContext.clip()

// 把original在rounded中画出来,也是显示出来的意思
original.draw(at: CGPoint.zero)
}

cell.imageView?.image = rounded

KeychainWrapper – class – 外部文件加载

我们一般使用UserDefaults在设备内存储普通信息,但有时需要存储相对敏感或是偏向私人的信息,在程序外一样可以读取到该UserDefaults的数据,所以为了不让他人从我们的手机数据中简单读取到,这时候就不建议使用UserDefaults了,而是使用KeychainWrapper这个外来的类。
但单单用KeychainWrapper来存储数据也不安全,最好是配合LA框架的TouchID和FaceID解锁程序更好。
要使用KeychainWrapper,先要在项目中放入两个文件,分别是:KeychainItemAccessibility.swiftKeychainWrapper.swift。这两个文件已放在extraFiles文件夹中。

KeychainWrapper.standard.set(_ value:String, forKey key:String ) – 存储数据

1
KeychainWrapper.standard.set(secret.text, forKey: "SecretMessage")

KeychainWrapper.standard.string(forKey: String) – 读取数据

1
let text = KeychainWrapper.standard.string(forKey: "SecretMessage") ?? ""

KeychainWrapper.standard.hasValue(forKey: String) – 判断是否有值

KeychainWrapper.standard.hasValue(forKey: passwordKey) 返回一个Bool

LocalAuthentication – framework 即 LA framework

https://www.hackingwithswift.com/read/28/4/touch-to-activate-touch-id-face-id-and-localauthentication

import

1
import LocalAuthentication

Touch ID 和 Face ID

获得 Touch ID 和 Face ID 的用户授权以及去验证是否取得授权的大致步骤:

  1. 检查设备是否支持Touch ID 和 Face ID, 或者说用户有没有在系统中设置过Touch ID 和 Face ID;
  2. 如果有,请求Touch ID 和 Face ID的授权。 当我们请求的时候,给用户一串我们为何要请求的原因的符串。当请求Touch ID的时候,我们把原因写在代码中就可以了,但请求Face ID的时候,把原因的字符串写在Info.plist文件里面–加一个key -> “Privacy - Face ID Usage Description.”
  3. 当我们请求成功的时候,我们就可以做我们想做的事情了,比如解锁这个app;不然,我们就要展示错误信息了。

注意:系统使用TouchID或FaceID,并不是两者都需要,只是挑一种,有一种就可以通过了。

LAContext的canEvaluatePolicy()和evaluatePolicy()方法 / .deviceOwnerAuthenticationWithBiometrics – 请求的安全条款类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@IBAction func authenticateTapped(_ sender: Any) {
let context = LAContext()
var error: NSError?

// 1.
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
let reason = "Identify yourself!"

// 2.
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { [weak self] success, authenticationError in

DispatchQueue.main.async {
if success {
self?.unlockSecretMessage()
} else {
// 3.
// error
let ac = UIAlertController(title: "Authentication failed", message: "You could not be verified; please try again.", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
self?.present(ac, animated: true)
}
}
}
} else {
// 3.
// no biometry
let ac = UIAlertController(title: "Biometry unavailable", message: "Your device is not configured for biometric authentication.", preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "OK", style: .default))
self.present(ac, animated: true)
}
}

Instruments – part of XCode

XCode中的Instruments,可以用来查看你的app的各种运行数据的工具。

Instruments的启动

Cmd + I
Instruments

Time Profiler

是图形化测试app中每个耗时的组件。

https://www.hackingwithswift.com/read/30/3/what-can-instruments-tell-us

在Debug菜单下面,可选择两个比较有用的选项:Color Blended Layers和Color Offscreen-Rendered。

  • Color Blended Layers shows views that are opaque in green and translucent in red. If there are multiple transparent views inside each other, you’ll see more and more red.
  • Color Offscreen-Rendered shows views that require an extra drawing pass in yellow. Some special drawing work must be drawn individually off screen then drawn again onto the screen, which means a lot more work.

Broadly speaking, you want “Color Blended Layers” to show as little red as possible, and “Color Offscreen-Rendered Yellow” to show no yellow.

Allocations

The allocations instrument will tell you how many of these objects are persistent (created and still exist) and how many are transient (created and since destroyed).
图表里persistent代表建立后仍旧存在的数量,transient代表建立后销毁的数量。

UIStackView

从名字上也能看出来这是干什么的,就是view的容器。

在UIStackView中设置其所包含元素的排列方式

https://www.hackingwithswift.com/read/31/2/uistackview-by-example
看上面的这篇文章,就能对各个情形有所了解了。

UIStackView的distribution属性

对在UIStackView中的布局形式进行设置

1
stackView.distribution = UIStackView.Distribution.fillEqually

以上貌似是将在UIStackView中的所以子元素设置为相同间距的布局。

addArrangedSubview() – 在UIStackView中添加元素

在UIStackView中添加子元素,不能使用addSubview(),而是需要使用addArrangedSubview(),这里要注意了。
比如:

1
2
3
4
5
6
// @IBOutlet var stackView: UIStackView!

let webView = WKWebView()
webView.navigationDelegate = self

stackView.addArrangedSubview(webView)

UIStackView的arrangedSubViews属性

可以用来显示UIStackView中的所有subViews。
比如:

1
2
3
for view in stackView.arrangedSubviews {
view.layer.borderWidth = 0
}

UIStackView的alignment属性

1
stackView.alignment = .center

UIStackView的axis属性

1
stackView.axis = .vertical

这样UIStackView中存储的views就会按照vertical来排列了。

CloudKit

需要注册一个开发者账号,所以还没有具体实践,后续如果需要写到,可以借鉴这个示例:

https://www.hackingwithswift.com/read/33/overview

GameplayKit

GameplayKit is an object-oriented framework that provides foundational tools and technologies for building games. GameplayKit includes tools for designing games with functional, reusable architecture, as well as technologies for building and enhancing gameplay features such as character movement and opponent behavior.

  • The GKGameModel protocol is used to represent the state of play, which means it needs to know where all the game pieces are, who the players are, what happens after each move is made, and what the score for a player is given any state.
  • The GKGameModelPlayer protocol is used to represent one player in the game. This protocol is so simple we already implemented it: all you need to do is make sure your player class has a playerId integer. It’s used to identify a player uniquely inside the AI.
  • The GKGameModelUpdate protocol is used to represent one possible move in the game. For us, that means storing a column number to represent a piece being played there. This protocol requires that you also store a value integer, which is used to rank all possible results by quality to help GameplayKit make a good choice.

GKGameModelPlayer – Protocol

playerId – property

Identifier used by GKMinmaxStrategist differentiate players from one another.
var playerId: Int { get }

遵循GKGameModelPlayer,必须要实现playerId属性.

GKGameModel – protocol

func gameModelUpdates(for player: GKGameModelPlayer) -> [GKGameModelUpdate]? {}

func apply(_ gameModelUpdate: GKGameModelUpdate) {}

func copy(with zone: NSZone? = nil) -> Any {}

func setGameModel(_ gameModel: GKGameModel) {}

how about the AI works

https://www.hackingwithswift.com/read/34/6/how-gameplaykit-ai-works-gkgamemodel-gkgamemodelplayer-and-gkgamemodelupdate

ISO8601DateFormatter – 日期与字符串之间的转换

国际标准ISO 8601,是国际标准化组织的日期和时间的表示方法,全称为《数据元和交换格式信息交换日期和时间表示法》。

例如:
2023-07-28T22:31:35Z

ISO8601DateFormatter().string(from date: Date) -> String

从Date格式转换为String格式。

1
2
3
let formatter = ISO8601DateFormatter()

let res = formatter.string(from: Date())

ISO8601DateFormatter().date(from string: String) -> Date?

从String格式转换为Date格式。

1
2
3
let formatter = ISO8601DateFormatter()

let res = formatter.date(from: "2023-07-28T22:31:35Z")

XCTest

https://www.hackingwithswift.com/read/39/overview

CustomStringConvertible

CustomStringConvertible可以在结构体、 类、 枚举等类型中实现, 只要完成description属性的实现即可, 最终可以完成对前述类型转成制定String类型格式的转换。

1
2
3
4
5
6
7
8
9
10
struct Point: CustomStringConvertible {
let x: Int, y: Int

var description: String {
return "The object: (\(x), \(y))"
}
}

var a = Point(x: 9, y: 10)
print(a) // "The object: (9, 10)\n"

IB – Interface Builder

@IBDesignable

@IBDesignable 可以让UIView实时在IB中显示,省去了执行任务后在模拟器上查看的步骤。

一般的用法:

1
2
3
4
5
6
import UIKit

@IBDesignable
class PlayingCardView: UIView {
// 代码省略
}

@IBInspectable

@IBInspectable 可以让你在IB中实时更改某个属性,并在IB中显示更改过的情况。

一般的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
import UIKit

@IBDesignable
class PlayingCardView: UIView {

@IBInspectable
var rank: Int = 5 {
didSet {
setNeedsDisplay()
setNeedsLayout()
}
}
}

IBInspectable