JavaScript的所有数据都可以看成对象,那是不是我们已经在使用面向对象编程了呢?

当然不是。如果我们只使用NumberArraystring以及基本的{...}定义的对象,还无法发挥出面向对象编程的威力。

JavaScript的面向对象编程和大多数其他语言如Java、C#的面向对象编程都不太一样。如果你熟悉Java或C#,很好,你一定明白面向对象的两个基本概念:

  1. 类:类是对象的类型模板,例如,定义Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生;
  2. 实例:实例是根据类创建的对象,例如,根据Student类可以创建出xiaomingxiaohongxiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。

所以,类和实例是大多数面向对象编程语言的基本概念。

不过,在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。

原型是指当我们想要创建xiaoming这个具体的学生时,我们并没有一个Student类型可用。那怎么办?恰好有这么一个现成的对象:

1
2
3
4
5
6
7
let robot = {
name: 'Robot',
height: 1.6,
run: function () {
console.log(this.name + ' is running...');
}
};

我们看这个robot对象有名字,有身高,还会跑,有点像小明,干脆就根据它来“创建”小明得了!

于是我们把它改名为Student,然后创建出xiaoming

1
2
3
4
5
6
7
8
9
10
11
12
13
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

let xiaoming = {
name: '小明'
};

xiaoming.__proto__ = Student;

注意最后一行代码把xiaoming的原型指向了对象Student,看上去xiaoming仿佛是从Student继承下来的:

1
2
xiaoming.name; // '小明'
xiaoming.run(); // 小明 is running...

xiaoming有自己的name属性,但并没有定义run()方法。不过,由于小明是从Student继承而来,只要Studentrun()方法,xiaoming也可以调用:

xiaoming-prototype

JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。

如果你把xiaoming的原型指向其他对象:

1
2
3
4
5
6
7
let Bird = {
fly: function () {
console.log(this.name + ' is flying...');
}
};

xiaoming.__proto__ = Bird;

现在xiaoming已经无法run()了,他已经变成了一只鸟:

1
xiaoming.fly(); // 小明 is flying...

在JavaScrip代码运行时期,你可以把xiaomingStudent变成Bird,或者变成任何对象。

请注意,上述代码仅用于演示目的。在编写JavaScript代码时,不要直接用obj.__proto__去改变一个对象的原型,并且,低版本的IE也无法使用__proto__Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有,因此,我们可以编写一个函数来创建xiaoming

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 原型对象:
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

function createStudent(name) {
// 基于Student原型创建一个新对象:
let s = Object.create(Student);
// 初始化新对象:
s.name = name;
return s;
}

let xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...
xiaoming.__proto__ === Student; // true

这是创建原型继承的一种方法,JavaScript还有其他方法来创建对象,我们在后面会一一讲到。

JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。

当我们用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined

例如,创建一个Array对象:

1
let arr = [1, 2, 3];

其原型链是:

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


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│ Array.prototype │
└─────────────────┘


┌─────────────────┐
│ arr │
└─────────────────┘

Array.prototype定义了indexOf()shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。

当我们创建一个函数时:

1
2
3
function foo() {
return 0;
}

函数也是一个对象,它的原型链是:

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


┌───────────────────┐
│ Object.prototype │
└───────────────────┘


┌───────────────────┐
│Function.prototype │
└───────────────────┘


┌───────────────────┐
│ foo │
└───────────────────┘

由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。

很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。

构造函数

除了直接用{ ... }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:

1
2
3
4
5
6
function Student(name) {
this.name = name;
this.hello = function () {
alert('Hello, ' + this.name + '!');
}
}

你会问,咦,这不是一个普通函数吗?

这确实是一个普通函数,但是在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:

1
2
3
let xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this,也就是说,不需要在最后写return this;

新创建的xiaoming的原型链是:

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


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│Student.prototype│
└─────────────────┘


┌─────────────────┐
│ xiaoming │
└─────────────────┘

也就是说,xiaoming的原型指向函数Student的原型。如果你又创建了xiaohongxiaojun,那么这些对象的原型与xiaoming是一样的:

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


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│Student.prototype│
└─────────────────┘
▲ ▲ ▲
│ │ │
┌─────────┐┌─────────┐┌─────────┐
│xiaoming ││xiaohong ││ xiaojun │
└─────────┘└─────────┘└─────────┘

new Student()创建的对象还从原型上获得了一个constructor属性,它指向函数Student本身:

1
2
3
4
5
6
xiaoming.constructor === Student.prototype.constructor; // true
Student.prototype.constructor === Student; // true

Object.getPrototypeOf(xiaoming) === Student.prototype; // true

xiaoming instanceof Student; // true

看晕了吧?用一张图来表示这些乱七八糟的关系就是:

prototypes

红色箭头是原型链。注意,Student.prototype指向的对象就是xiaomingxiaohong的原型对象,这个原型对象自己还有个属性constructor,指向Student函数本身。

另外,函数Student恰好有个属性prototype指向xiaomingxiaohong的原型对象,但是xiaomingxiaohong这些对象可没有prototype这个属性,不过可以用__proto__这个非标准用法来查看。

现在我们就认为xiaomingxiaohong这些对象“继承”自Student

不过还有一个小问题,注意观察:

1
2
3
4
5
xiaoming.name; // '小明'
xiaohong.name; // '小红'
xiaoming.hello; // function: Student.hello()
xiaohong.hello; // function: Student.hello()
xiaoming.hello === xiaohong.hello; // false

xiaomingxiaohong各自的name不同,这是对的,否则我们无法区分谁是谁了。

xiaomingxiaohong各自的hello是一个函数,但它们是两个不同的函数,虽然函数名称和代码都是相同的!

如果我们通过new Student()创建了很多对象,这些对象的hello函数实际上只需要共享同一个函数就可以了,这样可以节省很多内存。

要让创建的对象共享一个hello函数,根据对象的属性查找原则,我们只要把hello函数移动到xiaomingxiaohong这些对象共同的原型上就可以了,也就是Student.prototype

protos2

修改代码如下:

1
2
3
4
5
6
7
function Student(name) {
this.name = name;
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};

new创建基于原型的JavaScript的对象就是这么简单!

忘记写new怎么办

如果一个函数被定义为用于创建对象的构造函数,但是调用时忘记了写new怎么办?

在strict模式下,this.name = name将报错,因为this绑定为undefined,在非strict模式下,this.name = name不报错,因为this绑定为window,于是无意间创建了全局变量name,并且返回undefined,这个结果更糟糕。

所以,调用构造函数千万不要忘记写new。为了区分普通函数和构造函数,按照约定,构造函数首字母应当大写,而普通函数首字母应当小写,这样,一些语法检查工具如jslint将可以帮你检测到漏写的new

最后,我们还可以编写一个createStudent()函数,在内部封装所有的new操作。一个常用的编程模式像这样:

1
2
3
4
5
6
7
8
9
10
11
12
function Student(props) {
this.name = props.name || '匿名'; // 默认值为'匿名'
this.grade = props.grade || 1; // 默认值为1
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};

function createStudent(props) {
return new Student(props || {})
}

这个createStudent()函数有几个巨大的优点:一是不需要new来调用,二是参数非常灵活,可以不传,也可以这么传:

1
2
3
4
5
let xiaoming = createStudent({
name: '小明'
});

xiaoming.grade; // 1

如果创建的对象有很多属性,我们只需要传递需要的某些属性,剩下的属性可以用默认值。由于参数是一个Object,我们无需记忆参数的顺序。如果恰好从JSON拿到了一个对象,就可以直接创建出xiaoming

练习

请利用构造函数定义Cat,并让所有的Cat对象有一个name属性,并共享一个方法say(),返回字符串'Hello, xxx!'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Cat(name) {
// TODO:
}

// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if (kitty && kitty.name === 'Kitty'
&& kitty.say
&& typeof kitty.say === 'function'
&& kitty.say() === 'Hello, Kitty!'
&& kitty.say === doraemon.say
) {
console.log('测试通过!');
} else {
console.log('测试失败!');
}

在传统的基于Class的语言如Java、C++中,继承的本质是扩展一个已有的Class,并生成新的Subclass。

由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript由于采用原型继承,我们无法直接扩展一个Class,因为根本不存在Class这种类型。

但是办法还是有的。我们先回顾Student构造函数:

1
2
3
4
5
6
7
function Student(props) {
this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

以及Student的原型链:

js-proto

现在,我们要基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent

1
2
3
4
5
function PrimaryStudent(props) {
// 调用Student构造函数,绑定this变量:
Student.call(this, props);
this.grade = props.grade || 1;
}

但是,调用了Student构造函数不等于继承了StudentPrimaryStudent创建的对象的原型是:

1
new PrimaryStudent() --> PrimaryStudent.prototype --> Object.prototype --> null

必须想办法把原型链修改为:

1
new PrimaryStudent() --> PrimaryStudent.prototype --> Student.prototype --> Object.prototype --> null

这样,原型链对了,继承关系就对了。新的基于PrimaryStudent创建的对象不但能调用PrimaryStudent.prototype定义的方法,也可以调用Student.prototype定义的方法。

如果你想用最简单粗暴的方法这么干:

1
PrimaryStudent.prototype = Student.prototype;

是不行的!如果这样的话,PrimaryStudentStudent共享一个原型对象,那还要定义PrimaryStudent干啥?

我们必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,参考道爷(就是发明JSON的那个道格拉斯)的代码,中间对象可以用一个空函数F来实现:

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
// PrimaryStudent构造函数:
function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}

// 空函数F:
function F() {
}

// 把F的原型指向Student.prototype:
F.prototype = Student.prototype;

// 把PrimaryStudent的原型指向一个新的F对象,F对象的原型正好指向Student.prototype:
PrimaryStudent.prototype = new F();

// 把PrimaryStudent原型的构造函数修复为PrimaryStudent:
PrimaryStudent.prototype.constructor = PrimaryStudent;

// 继续在PrimaryStudent原型(就是new F()对象)上定义方法:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};

// 创建xiaoming:
let xiaoming = new PrimaryStudent({
name: '小明',
grade: 2
});
xiaoming.name; // '小明'
xiaoming.grade; // 2

// 验证原型:
xiaoming.__proto__ === PrimaryStudent.prototype; // true
xiaoming.__proto__.__proto__ === Student.prototype; // true

// 验证继承关系:
xiaoming instanceof PrimaryStudent; // true
xiaoming instanceof Student; // true

用一张图来表示新的原型链:

js-proto-inherit

注意,函数F仅用于桥接,我们仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。

如果把继承这个动作用一个inherits()函数封装起来,还可以隐藏F的定义,并简化代码:

1
2
3
4
5
6
function inherits(Child, Parent) {
let F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}

这个inherits()函数可以复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Student(props) {
this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}

// 实现原型继承链:
inherits(PrimaryStudent, Student);

// 绑定其他方法到PrimaryStudent原型:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};

小结

JavaScript的原型继承实现方式就是:

  1. 定义新的构造函数,并在内部用call()调用希望“继承”的构造函数,并绑定this
  2. 借助中间函数F实现原型链继承,最好通过封装的inherits函数完成;
  3. 继续在新的构造函数的原型上定义新方法。

class继承

在上面的章节中我们看到了JavaScript的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。

有没有更简单的写法?有!

新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。

我们先回顾用函数实现Student的方法:

1
2
3
4
5
6
7
function Student(name) {
this.name = name;
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

如果用新的class关键字来编写Student,可以这样写:

1
2
3
4
5
6
7
8
9
class Student {
constructor(name) {
this.name = name;
}

hello() {
alert('Hello, ' + this.name + '!');
}
}

比较一下就可以发现,class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}这样分散的代码。

最后,创建一个Student对象代码和前面章节完全一样:

1
2
let xiaoming = new Student('小明');
xiaoming.hello();

class继承

class定义对象的另一个巨大的好处是继承更方便了。想一想我们从Student派生一个PrimaryStudent需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过extends来实现:

1
2
3
4
5
6
7
8
9
10
class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用super调用父类的构造方法!
this.grade = grade;
}

myGrade() {
alert('I am at grade ' + this.grade);
}
}

注意PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要namegrade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。

PrimaryStudent已经自动获得了父类Studenthello方法,我们又在子类中定义了新的myGrade方法。

ES6引入的class和原有的JavaScript原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让JavaScript引擎去实现原来需要我们自己编写的原型链代码。简而言之,用class的好处就是极大地简化了原型链代码。

练习

请利用class重新定义Cat,并让它从已有的Animal继承,然后新增一个方法say(),返回字符串'Hello, xxx!'

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 Animal {
constructor(name) {
this.name = name;
}
}

// FIXME:
class Cat ???

// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if ((new Cat('x') instanceof Animal)
&& kitty
&& kitty.name === 'Kitty'
&& kitty.say
&& typeof kitty.say === 'function'
&& kitty.say() === 'Hello, Kitty!'
&& kitty.say === doraemon.say)
{
console.log('测试通过!');
} else {
console.log('测试失败!');
}

这个练习需要浏览器支持ES6的class,如果遇到SyntaxError,则说明浏览器不支持class语法,请换一个最新的浏览器试试。



留言與分享

JavaScript-标准对象

分類 编程语言, JavaScript

标准对象

在JavaScript的世界里,一切都是对象。

但是某些对象还是和其他对象不太一样。为了区分对象的类型,我们用typeof操作符获取对象的类型,它总是返回一个字符串:

1
2
3
4
5
6
7
8
9
10
typeof 123; // 'number'
typeof 123n; // 'bigint'
typeof NaN; // 'number'
typeof 'str'; // 'string'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof Math.abs; // 'function'
typeof null; // 'object'
typeof []; // 'object'
typeof {}; // 'object'

可见,numberbigintstringbooleanfunctionundefined有别于其他类型。特别注意null的类型是objectArray的类型也是object,如果我们用typeof将无法区分出nullArray和通常意义上的object——{}

包装对象

除了这些类型外,JavaScript还提供了包装对象,熟悉Java的小伙伴肯定很清楚intInteger这种暧昧关系。

numberbooleanstring都有包装对象。没错,在JavaScript中,字符串也区分string类型和它的包装类型。包装对象用new创建:

1
2
3
let n = new Number(123); // 123,生成了新的包装类型
let b = new Boolean(true); // true,生成了新的包装类型
let s = new String('str'); // 'str',生成了新的包装类型

虽然包装对象看上去和原来的值一模一样,显示出来也是一模一样,但他们的类型已经变为object了!所以,包装对象和原始值用===比较会返回false

1
2
3
4
5
6
7
8
typeof new Number(123); // 'object'
new Number(123) === 123; // false

typeof new Boolean(true); // 'object'
new Boolean(true) === true; // false

typeof new String('str'); // 'object'
new String('str') === 'str'; // false

所以闲的蛋疼也不要使用包装对象!尤其是针对string类型!!!

如果我们在使用NumberBooleanString时,没有写new会发生什么情况?

此时,Number()BooleanString()被当做普通函数,把任何类型的数据转换为numberbooleanstring类型(注意不是其包装类型):

1
2
3
4
5
6
7
8
9
10
11
let n = Number('123'); // 123,相当于parseInt()或parseFloat()
typeof n; // 'number'

let b = Boolean('true'); // true
typeof b; // 'boolean'

let b2 = Boolean('false'); // true! 'false'字符串转换结果为true!因为它是非空字符串!
let b3 = Boolean(''); // false

let s = String(123.45); // '123.45'
typeof s; // 'string'

是不是感觉头大了?这就是JavaScript特有的催眠魅力!

总结一下,有这么几条规则需要遵守:

  • 不要使用new Number()new Boolean()new String()创建包装对象;
  • parseInt()parseFloat()来转换任意类型到number
  • String()来转换任意类型到string,或者直接调用某个对象的toString()方法;
  • 通常不必把任意类型转换为boolean再判断,因为可以直接写if (myVar) {...}
  • typeof操作符可以判断出numberbooleanstringfunctionundefined
  • 判断Array要使用Array.isArray(arr)
  • 判断null请使用myVar === null
  • 判断某个全局变量是否存在用typeof window.myVar === 'undefined'
  • 函数内部判断某个变量是否存在用typeof myVar === 'undefined'

最后有细心的同学指出,任何对象都有toString()方法吗?nullundefined就没有!确实如此,这两个特殊值要除外,虽然null还伪装成了object类型。

更细心的同学指出,number对象调用toString()报SyntaxError:

1
123.toString(); // SyntaxError

遇到这种情况,要特殊处理一下:

1
2
123..toString(); // '123', 注意是两个点!
(123).toString(); // '123'

不要问为什么,这就是JavaScript代码的乐趣!



在JavaScript中,Date对象用来表示日期和时间。

要获取系统当前时间,用:

1
2
3
4
5
6
7
8
9
10
11
let now = new Date();
now; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
now.getFullYear(); // 2015, 年份
now.getMonth(); // 5, 月份,注意月份范围是0~11,5表示六月
now.getDate(); // 24, 表示24号
now.getDay(); // 3, 表示星期三
now.getHours(); // 19, 24小时制
now.getMinutes(); // 49, 分钟
now.getSeconds(); // 22, 秒
now.getMilliseconds(); // 875, 毫秒数
now.getTime(); // 1435146562875, 以number形式表示的时间戳

注意,当前时间是浏览器从本机操作系统获取的时间,所以不一定准确,因为用户可以把当前时间设定为任何值。

如果要创建一个指定日期和时间的Date对象,可以用:

1
2
let d = new Date(2015, 5, 19, 20, 15, 30, 123);
console.log(d); // Fri Jun 19 2015 20:15:30 GMT+0800 (CST)

你可能观察到了一个非常非常坑爹的地方,就是JavaScript的月份范围用整数表示是0~11,0表示一月,1表示二月……,所以要表示6月,我们传入的是5!这绝对是JavaScript的设计者当时脑抽了一下,但是现在要修复已经不可能了。

特别注意

JavaScript的Date对象月份值从0开始,牢记0=1月,1=2月,2=3月,……,11=12月。

第二种创建一个指定日期和时间的方法是解析一个符合ISO 8601格式的字符串:

1
2
let d = Date.parse('2015-06-24T19:49:22.875+08:00');
console.log(d); // 1435146562875

但它返回的不是Date对象,而是一个时间戳。不过有时间戳就可以很容易地把它转换为一个Date

1
2
3
let d = new Date(1435146562875);
d; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
d.getMonth(); // 5

注意

使用Date.parse()时传入的字符串使用实际月份01~12,转换为Date对象后getMonth()获取的月份值为0~11。

时区

Date对象表示的时间总是按浏览器所在时区显示的,不过我们既可以显示本地时间,也可以显示调整后的UTC时间:

1
2
3
let d = new Date(1435146562875);
d.toLocaleString(); // '2015/6/24 下午7:49:22',本地时间(北京时区+8:00),显示的字符串与操作系统设定的格式有关
d.toUTCString(); // 'Wed, 24 Jun 2015 11:49:22 GMT',UTC时间,与本地时间相差8小时

那么在JavaScript中如何进行时区转换呢?实际上,只要我们传递的是一个number类型的时间戳,我们就不用关心时区转换。任何浏览器都可以把一个时间戳正确转换为本地时间。

时间戳是个什么东西?时间戳是一个自增的整数,它表示从1970年1月1日零时整的GMT时区开始的那一刻,到现在的毫秒数。假设浏览器所在电脑的时间是准确的,那么世界上无论哪个时区的电脑,它们此刻产生的时间戳数字都是一样的,所以,时间戳可以精确地表示一个时刻,并且与时区无关。

所以,我们只需要传递时间戳,或者把时间戳从数据库里读出来,再让JavaScript自动转换为当地时间就可以了。

要获取当前时间戳,可以用:

1
2
3
4
5
// 方法1:
console.log(Date.now());

// 方法2:
console.log(new Date().getTime());

练习

小明为了和女友庆祝情人节,特意制作了网页,并提前预定了法式餐厅。小明打算用JavaScript给女友一个惊喜留言:

1
2
3
4
let today = new Date();
if (today.getMonth() === 2 && today.getDate() === 14) {
alert('亲爱的,我预定了晚餐,晚上6点在餐厅见!');
}

结果女友并未出现。小明非常郁闷,请你帮忙分析他的JavaScript代码有何问题。

注孤生

字符串是编程时涉及到的最多的一种数据结构,对字符串进行操作的需求几乎无处不在。比如判断一个字符串是否是合法的Email地址,虽然可以编程提取@前后的子串,再分别判断是否是单词和域名,但这样做不但麻烦,而且代码难以复用。

正则表达式是一种用来匹配字符串的强有力的武器。它的设计思想是用一种描述性的语言来给字符串定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的。

所以我们判断一个字符串是否是合法的Email的方法是:

  1. 创建一个匹配Email的正则表达式;
  2. 用该正则表达式去匹配用户的输入来判断是否合法。

因为正则表达式也是用字符串表示的,所以,我们要首先了解如何用字符来描述字符。

在正则表达式中,如果直接给出字符,就是精确匹配。用\d可以匹配一个数字,\w可以匹配一个字母或数字,所以:

  • '00\d'可以匹配'007',但无法匹配'00A'
  • '\d\d\d'可以匹配'010'
  • '\w\w'可以匹配'js'

.可以匹配任意字符,所以:

  • 'js.'可以匹配'jsp''jss''js!'等等。

要匹配变长的字符,在正则表达式中,用*表示任意个字符(包括0个),用+表示至少一个字符,用?表示0个或1个字符,用{n}表示n个字符,用{n,m}表示n-m个字符:

来看一个复杂的例子:\d{3}\s+\d{3,8}

我们来从左到右解读一下:

  1. \d{3}表示匹配3个数字,例如'010'
  2. \s可以匹配一个空格(也包括Tab等空白符),所以\s+表示至少有一个空格,例如匹配' ''\t\t'等;
  3. \d{3,8}表示3-8个数字,例如'1234567'

综合起来,上面的正则表达式可以匹配以任意个空格隔开的带区号的电话号码。

如果要匹配'010-12345'这样的号码呢?由于'-'是特殊字符,在正则表达式中,要用'\'转义,所以,上面的正则是\d{3}\-\d{3,8}

但是,仍然无法匹配'010 - 12345',因为带有空格。所以我们需要更复杂的匹配方式。

进阶

要做更精确地匹配,可以用[]表示范围,比如:

  • [0-9a-zA-Z\_]可以匹配一个数字、字母或者下划线;
  • [0-9a-zA-Z\_]+可以匹配至少由一个数字、字母或者下划线组成的字符串,比如'a100''0_Z''js2015'等等;
  • [a-zA-Z\_\$][0-9a-zA-Z\_\$]*可以匹配由字母或下划线、开头,后接任意个由一个数字、字母或者下划线、开头,后接任意个由一个数字、字母或者下划线、组成的字符串,也就是JavaScript允许的变量名;
  • [a-zA-Z\_\$][0-9a-zA-Z\_\$]{0, 19}更精确地限制了变量的长度是1-20个字符(前面1个字符+后面最多19个字符)。

A|B可以匹配A或B,所以(J|j)ava(S|s)cript可以匹配'JavaScript''Javascript''javaScript'或者'javascript'

^表示行的开头,^\d表示必须以数字开头。

$表示行的结束,\d$表示必须以数字结束。

你可能注意到了,js也可以匹配'jsp',但是加上^js$就变成了整行匹配,就只能匹配'js'了。

RegExp

有了准备知识,我们就可以在JavaScript中使用正则表达式了。

JavaScript有两种方式创建一个正则表达式:

第一种方式是直接通过/正则表达式/写出来,第二种方式是通过new RegExp('正则表达式')创建一个RegExp对象。

两种写法是一样的:

1
2
3
4
5
let re1 = /ABC\-001/;
let re2 = new RegExp('ABC\\-001');

re1; // /ABC\-001/
re2; // /ABC\-001/

注意,如果使用第二种写法,因为字符串的转义问题,字符串的两个\\实际上是一个\

先看看如何判断正则表达式是否匹配:

1
2
3
4
let re = /^\d{3}\-\d{3,8}$/;
re.test('010-12345'); // true
re.test('010-1234x'); // false
re.test('010 12345'); // false

RegExp对象的test()方法用于测试给定的字符串是否符合条件。

切分字符串

用正则表达式切分字符串比用固定的字符更灵活,请看正常的切分代码:

1
'a b   c'.split(' '); // ['a', 'b', '', '', 'c']

嗯,无法识别连续的空格,用正则表达式试试:

1
'a b   c'.split(/\s+/); // ['a', 'b', 'c']

无论多少个空格都可以正常分割。加入,试试:

1
'a,b, c  d'.split(/[\s\,]+/); // ['a', 'b', 'c', 'd']

再加入;试试:

1
'a,b;; c  d'.split(/[\s\,\;]+/); // ['a', 'b', 'c', 'd']

如果用户输入了一组标签,下次记得用正则表达式来把不规范的输入转化成正确的数组。

分组

除了简单地判断是否匹配之外,正则表达式还有提取子串的强大功能。用()表示的就是要提取的分组(Group)。比如:

^(\d{3})-(\d{3,8})$分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:

1
2
3
let re = /^(\d{3})-(\d{3,8})$/;
re.exec('010-12345'); // ['010-12345', '010', '12345']
re.exec('010 12345'); // null

如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。

exec()方法在匹配成功后,会返回一个Array,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串。

exec()方法在匹配失败时返回null

提取子串非常有用。来看一个更凶残的例子:

1
2
let re = /^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])$/;
re.exec('19:05:30'); // ['19:05:30', '19', '05', '30']

这个正则表达式可以直接识别合法的时间。但是有些时候,用正则表达式也无法做到完全验证,比如识别日期:

1
let re = /^(0[1-9]|1[0-2]|[0-9])-(0[1-9]|1[0-9]|2[0-9]|3[0-1]|[0-9])$/;

对于'2-30''4-31'这样的非法日期,用正则还是识别不了,或者说写出来非常困难,这时就需要程序配合识别了。

贪婪匹配

需要特别指出的是,正则匹配默认是贪婪匹配,也就是匹配尽可能多的字符。举例如下,匹配出数字后面的0

1
2
let re = /^(\d+)(0*)$/;
re.exec('102300'); // ['102300', '102300', '']

由于\d+采用贪婪匹配,直接把后面的0全部匹配了,结果0*只能匹配空字符串了。

必须让\d+采用非贪婪匹配(也就是尽可能少匹配),才能把后面的0匹配出来,加个?就可以让\d+采用非贪婪匹配:

1
2
let re = /^(\d+?)(0*)$/;
re.exec('102300'); // ['102300', '1023', '00']

全局搜索

JavaScript的正则表达式还有几个特殊的标志,最常用的是g,表示全局匹配:

1
2
3
let r1 = /test/g;
// 等价于:
let r2 = new RegExp('test', 'g');

全局匹配可以多次执行exec()方法来搜索一个匹配的字符串。当我们指定g标志后,每次运行exec(),正则表达式本身会更新lastIndex属性,表示上次匹配到的最后索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let s = 'JavaScript, VBScript, JScript and ECMAScript';
let re=/[a-zA-Z]+Script/g;

// 使用全局匹配:
re.exec(s); // ['JavaScript']
re.lastIndex; // 10

re.exec(s); // ['VBScript']
re.lastIndex; // 20

re.exec(s); // ['JScript']
re.lastIndex; // 29

re.exec(s); // ['ECMAScript']
re.lastIndex; // 44

re.exec(s); // null,直到结束仍没有匹配到

全局匹配类似搜索,因此不能使用/^...$/,那样只会最多匹配一次。

正则表达式还可以指定i标志,表示忽略大小写,m标志,表示执行多行匹配。

小结

正则表达式非常强大,要在短短的一节里讲完是不可能的。要讲清楚正则的所有内容,可以写一本厚厚的书了。如果你经常遇到正则表达式的问题,你可能需要一本正则表达式的参考书。

练习

请尝试写一个验证Email地址的正则表达式。版本一应该可以验证出类似的Email:

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
let re = /^$/;

// 测试:
let
i,
success = true,
should_pass = ['someone@gmail.com', 'bill.gates@microsoft.com', 'tom@voyager.org', 'bob2015@163.com'],
should_fail = ['test#gmail.com', 'bill@microsoft', 'bill%gates@ms.com', '@voyager.org'];
for (i = 0; i < should_pass.length; i++) {
if (!re.test(should_pass[i])) {
console.log('测试失败: ' + should_pass[i]);
success = false;
break;
}
}
for (i = 0; i < should_fail.length; i++) {
if (re.test(should_fail[i])) {
console.log('测试失败: ' + should_fail[i]);
success = false;
break;
}
}
if (success) {
console.log('测试通过!');
}

版本二可以验证并提取出带名字的Email地址:

1
2
3
4
5
6
7
8
9
10
let re = /^$/;

// 测试:
let r = re.exec('<Tom Paris> tom@voyager.org');
if (r === null || r.toString() !== ['<Tom Paris> tom@voyager.org', 'Tom Paris', 'tom@voyager.org'].toString()) {
console.log('测试失败!');
}
else {
console.log('测试成功!');
}

JSON是JavaScript Object Notation的缩写,它是一种数据交换格式。

在JSON出现之前,大家一直用XML来传递数据。因为XML是一种纯文本格式,所以它适合在网络上交换数据。XML本身不算复杂,但是,加上DTD、XSD、XPath、XSLT等一大堆复杂的规范以后,任何正常的软件开发人员碰到XML都会感觉头大了,最后大家发现,即使你努力钻研几个月,也未必搞得清楚XML的规范。

终于,在2002年的一天,道格拉斯·克罗克福特(Douglas Crockford)同学为了拯救深陷水深火热同时又被某几个巨型软件企业长期愚弄的软件工程师,发明了JSON这种超轻量级的数据交换格式。

道格拉斯同学长期担任雅虎的高级架构师,自然钟情于JavaScript。他设计的JSON实际上是JavaScript的一个子集。在JSON中,一共就这么几种数据类型:

  • number:和JavaScript的number完全一致;
  • boolean:就是JavaScript的truefalse
  • string:就是JavaScript的string
  • null:就是JavaScript的null
  • array:就是JavaScript的Array表示方式——[]
  • object:就是JavaScript的{ ... }表示方式。

以及上面的任意组合。

并且,JSON还定死了字符集必须是UTF-8,表示多语言就没有问题了。为了统一解析,JSON的字符串规定必须用双引号"",Object的键也必须用双引号""

由于JSON非常简单,很快就风靡Web世界,并且成为ECMA标准。几乎所有编程语言都有解析JSON的库,而在JavaScript中,我们可以直接使用JSON,因为JavaScript内置了JSON的解析。

把任何JavaScript对象变成JSON,就是把这个对象序列化成一个JSON格式的字符串,这样才能够通过网络传递给其他计算机。

如果我们收到一个JSON格式的字符串,只需要把它反序列化成一个JavaScript对象,就可以在JavaScript中直接使用这个对象了。

序列化

让我们先把小明这个对象序列化成JSON格式的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
let xiaoming = {
name: '小明',
age: 14,
gender: true,
height: 1.65,
grade: null,
'middle-school': '\"W3C\" Middle School',
skills: ['JavaScript', 'Java', 'Python', 'Lisp']
};

let s = JSON.stringify(xiaoming);
console.log(s);

要输出得好看一些,可以加上参数,按缩进输出:

1
JSON.stringify(xiaoming, null, '  ');

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "小明",
"age": 14,
"gender": true,
"height": 1.65,
"grade": null,
"middle-school": "\"W3C\" Middle School",
"skills": [
"JavaScript",
"Java",
"Python",
"Lisp"
]
}

第二个参数用于控制如何筛选对象的键值,如果我们只想输出指定的属性,可以传入Array

1
JSON.stringify(xiaoming, ['name', 'skills'], '  ');

结果:

1
2
3
4
5
6
7
8
9
{
"name": "小明",
"skills": [
"JavaScript",
"Java",
"Python",
"Lisp"
]
}

还可以传入一个函数,这样对象的每个键值对都会被函数先处理:

1
2
3
4
5
6
7
8
function convert(key, value) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value;
}

JSON.stringify(xiaoming, convert, ' ');

上面的代码把所有属性值都变成大写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "小明",
"age": 14,
"gender": true,
"height": 1.65,
"grade": null,
"middle-school": "\"W3C\" MIDDLE SCHOOL",
"skills": [
"JAVASCRIPT",
"JAVA",
"PYTHON",
"LISP"
]
}

如果我们还想要精确控制如何序列化小明,可以给xiaoming定义一个toJSON()的方法,直接返回JSON应该序列化的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let xiaoming = {
name: '小明',
age: 14,
gender: true,
height: 1.65,
grade: null,
'middle-school': '\"W3C\" Middle School',
skills: ['JavaScript', 'Java', 'Python', 'Lisp'],
toJSON: function () {
return { // 只输出name和age,并且改变了key:
'Name': this.name,
'Age': this.age
};
}
};

JSON.stringify(xiaoming); // '{"Name":"小明","Age":14}'

反序列化

拿到一个JSON格式的字符串,我们直接用JSON.parse()把它变成一个JavaScript对象:

1
2
3
4
JSON.parse('[1,2,3,true]'); // [1, 2, 3, true]
JSON.parse('{"name":"小明","age":14}'); // Object {name: '小明', age: 14}
JSON.parse('true'); // true
JSON.parse('123.45'); // 123.45

JSON.parse()还可以接收一个函数,用来转换解析出的属性:

1
2
3
4
5
6
7
let obj = JSON.parse('{"name":"小明","age":14}', function (key, value) {
if (key === 'name') {
return value + '同学';
}
return value;
});
console.log(JSON.stringify(obj)); // {name: '小明同学', age: 14}

在JavaScript中使用JSON,就是这么简单!

练习

用浏览器访问OpenWeatherMap的天气API,查看返回的JSON数据,然后返回城市、天气预报等信息:

1
2
3
4
5
6
7
8
9
10
11
let url = 'https://api.openweathermap.org/data/2.5/forecast?q=Beijing,cn&appid=800f49846586c3ba6e7052cfc89af16c';
fetch(url).then(resp => {
resp.json().then(data => {
let info = {
city: data.city.name,
weather: data.list[0].weather[0].main,
time: data.list[0].dt_txt
};
alert(JSON.stringify(info, null, ' '));
});
});

留言與分享

JavaScript-函数

分類 编程语言, JavaScript

函数

我们知道圆的面积计算公式为:

当我们知道半径r的值时,就可以根据公式计算出面积。假设我们需要计算3个不同大小的圆的面积:

1
2
3
4
5
6
let r1 = 12.34;
let r2 = 9.08;
let r3 = 73.1;
let s1 = 3.14 * r1 * r1;
let s2 = 3.14 * r2 * r2;
let s3 = 3.14 * r3 * r3;

当代码出现有规律的重复的时候,你就需要当心了,每次写3.14 * x * x不仅很麻烦,而且,如果要把3.14改成3.1416的时候,得全部替换。

有了函数,我们就不再每次写s = 3.14 * x * x,而是写成更有意义的函数调用s = area_of_circle(x),而函数area_of_circle本身只需要写一次,就可以多次调用。

基本上所有的高级语言都支持函数,JavaScript也不例外。JavaScript的函数不但是“头等公民”,而且可以像变量一样使用,具有非常强大的抽象能力。

抽象

抽象是数学中非常常见的概念。举个例子:

计算数列的和,比如:1 + 2 + 3 + ... + 100,写起来十分不方便,于是数学家发明了求和符号∑,可以把1 + 2 + 3 + ... + 100记作:

这种抽象记法非常强大,因为我们看到 ∑ 就可以理解成求和,而不是还原成低级的加法运算。

而且,这种抽象记法是可扩展的,比如:

还原成加法运算就变成了:

可见,借助抽象,我们才能不关心底层的具体计算过程,而直接在更高的层次上思考问题。

写计算机程序也是一样,函数就是最基本的一种代码抽象的方式。



定义函数

在JavaScript中,定义函数的方式如下:

1
2
3
4
5
6
7
function abs(x) {
if (x >= 0) {
return x;
} else {
return -x;
}
}

上述abs()函数的定义如下:

  • function指出这是一个函数定义;
  • abs是函数的名称;
  • (x)括号内列出函数的参数,多个参数以,分隔;
  • { ... }之间的代码是函数体,可以包含若干语句,甚至可以没有任何语句。

请注意,函数体内部的语句在执行时,一旦执行到return时,函数就执行完毕,并将结果返回。因此,函数内部通过条件判断和循环可以实现非常复杂的逻辑。

如果没有return语句,函数执行完毕后也会返回结果,只是结果为undefined

由于JavaScript的函数也是一个对象,上述定义的abs()函数实际上是一个函数对象,而函数名abs可以视为指向该函数的变量。

因此,第二种定义函数的方式如下:

1
2
3
4
5
6
7
let abs = function (x) {
if (x >= 0) {
return x;
} else {
return -x;
}
};

在这种方式下,function (x) { ... }是一个匿名函数,它没有函数名。但是,这个匿名函数赋值给了变量abs,所以,通过变量abs就可以调用该函数。

上述两种定义完全等价,注意第二种方式按照完整语法需要在函数体末尾加一个;,表示赋值语句结束。

调用函数

调用函数时,按顺序传入参数即可:

1
2
abs(10); // 返回10
abs(-9); // 返回9

由于JavaScript允许传入任意个参数而不影响调用,因此传入的参数比定义的参数多也没有问题,虽然函数内部并不需要这些参数:

1
2
abs(10, 'blablabla'); // 返回10
abs(-9, 'haha', 'hehe', null); // 返回9

传入的参数比定义的少也没有问题:

1
abs(); // 返回NaN

此时abs(x)函数的参数x将收到undefined,计算结果为NaN

要避免收到undefined,可以对参数进行检查:

1
2
3
4
5
6
7
8
9
10
function abs(x) {
if (typeof x !== 'number') {
throw 'Not a number';
}
if (x >= 0) {
return x;
} else {
return -x;
}
}

arguments

JavaScript还有一个免费赠送的关键字arguments,它只在函数内部起作用,并且永远指向当前函数的调用者传入的所有参数。arguments类似Array但它不是一个Array

1
2
3
4
5
6
7
function foo(x) {
console.log('x = ' + x); // 10
for (let i=0; i<arguments.length; i++) {
console.log('arg ' + i + ' = ' + arguments[i]); // 10, 20, 30
}
}
foo(10, 20, 30);

利用arguments,你可以获得调用者传入的所有参数。也就是说,即使函数不定义任何参数,还是可以拿到参数的值:

1
2
3
4
5
6
7
8
9
10
11
function abs() {
if (arguments.length === 0) {
return 0;
}
let x = arguments[0];
return x >= 0 ? x : -x;
}

abs(); // 0
abs(10); // 10
abs(-9); // 9

实际上arguments最常用于判断传入参数的个数。你可能会看到这样的写法:

1
2
3
4
5
6
7
8
9
10
// foo(a[, b], c)
// 接收2~3个参数,b是可选参数,如果只传2个参数,b默认为null:
function foo(a, b, c) {
if (arguments.length === 2) {
// 实际拿到的参数是a和b,c为undefined
c = b; // 把b赋给c
b = null; // b变为默认值
}
// ...
}

要把中间的参数b变为“可选”参数,就只能通过arguments判断,然后重新调整参数并赋值。

rest参数

由于JavaScript函数允许接收任意个参数,于是我们就不得不用arguments来获取所有参数:

1
2
3
4
5
6
7
8
9
10
11
function foo(a, b) {
let i, rest = [];
if (arguments.length > 2) {
for (i = 2; i<arguments.length; i++) {
rest.push(arguments[i]);
}
}
console.log('a = ' + a);
console.log('b = ' + b);
console.log(rest);
}

为了获取除了已定义参数ab之外的参数,我们不得不用arguments,并且循环要从索引2开始以便排除前两个参数,这种写法很别扭,只是为了获得额外的rest参数,有没有更好的方法?

ES6标准引入了rest参数,上面的函数可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(a, b, ...rest) {
console.log('a = ' + a);
console.log('b = ' + b);
console.log(rest);
}

foo(1, 2, 3, 4, 5);
// 结果:
// a = 1
// b = 2
// Array [ 3, 4, 5 ]

foo(1);
// 结果:
// a = 1
// b = undefined
// Array []

rest参数只能写在最后,前面用...标识,从运行结果可知,传入的参数先绑定ab,多余的参数以数组形式交给变量rest,所以,不再需要arguments我们就获取了全部参数。

如果传入的参数连正常定义的参数都没填满,也不要紧,rest参数会接收一个空数组(注意不是undefined)。

因为rest参数是ES6新标准,所以你需要测试一下浏览器是否支持。请用rest参数编写一个sum()函数,接收任意个参数并返回它们的和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function sum(...rest) {
???
}

// 测试:
let i, args = [];
for (i=1; i<=100; i++) {
args.push(i);
}
if (sum() !== 0) {
console.log('测试失败: sum() = ' + sum());
} else if (sum(1) !== 1) {
console.log('测试失败: sum(1) = ' + sum(1));
} else if (sum(2, 3) !== 5) {
console.log('测试失败: sum(2, 3) = ' + sum(2, 3));
} else if (sum.apply(null, args) !== 5050) {
console.log('测试失败: sum(1, 2, 3, ..., 100) = ' + sum.apply(null, args));
} else {
console.log('测试通过!');
}

小心你的return语句

前面我们讲到了JavaScript引擎有一个在行末自动添加分号的机制,这可能让你栽到return语句的一个大坑:

1
2
3
4
5
function foo() {
return { name: 'foo' };
}

foo(); // { name: 'foo' }

如果把return语句拆成两行:

1
2
3
4
5
6
function foo() {
return
{ name: 'foo' };
}

foo(); // undefined

要小心了,由于JavaScript引擎在行末自动添加分号的机制,上面的代码实际上变成了:

1
2
3
4
function foo() {
return; // 自动添加了分号,相当于return undefined;
{ name: 'foo' }; // 这行语句已经没法执行到了
}

所以正确的多行写法是:

1
2
3
4
5
function foo() {
return { // 这里不会自动加分号,因为{表示语句尚未结束
name: 'foo'
};
}

练习

定义一个计算圆面积的函数area_of_circle(),它有两个参数:

  • r: 表示圆的半径;
  • pi: 表示π的值,如果不传,则默认3.14
1
2
3
4
5
6
7
8
9
10
function area_of_circle(r, pi) {
// FIXME:
return 0;
}
// 测试:
if (area_of_circle(2) === 12.56 && area_of_circle(2, 3.1416) === 12.5664) {
console.log('测试通过');
} else {
console.log('测试失败');
}

小明是一个JavaScript新手,他写了一个max()函数,返回两个数中较大的那个:

1
2
3
4
5
6
7
8
9
10
function max(a, b) {
if (a > b) {
return
a;
} else {
return
b;
}
}
console.log(max(15, 20));

但是小明抱怨他的浏览器出问题了,无论传入什么数,max()函数总是返回undefined。请帮他指出问题并修复。

在JavaScript中,用var申明的变量实际上是有作用域的。

如果一个变量在函数体内部申明,则该变量的作用域为整个函数体,在函数体外不可引用该变量:

1
2
3
4
5
6
function foo() {
var x = 1;
x = x + 1;
}

x = x + 2; // ReferenceError! 无法在函数体外引用变量x

如果两个不同的函数各自申明了同一个变量,那么该变量只在各自的函数体内起作用。换句话说,不同函数内部的同名变量互相独立,互不影响:

1
2
3
4
5
6
7
8
9
function foo() {
var x = 1;
x = x + 1;
}

function bar() {
var x = 'A';
x = x + 'B';
}

由于JavaScript的函数可以嵌套,此时,内部函数可以访问外部函数定义的变量,反过来则不行:

1
2
3
4
5
6
7
function foo() {
var x = 1;
function bar() {
var y = x + 1; // bar可以访问foo的变量x!
}
var z = y + 1; // ReferenceError! foo不可以访问bar的变量y!
}

如果内部函数和外部函数的变量名重名怎么办?来测试一下:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var x = 1;
function bar() {
var x = 'A';
console.log('x in bar() = ' + x); // 'A'
}
console.log('x in foo() = ' + x); // 1
bar();
}

foo();

这说明JavaScript的函数在查找变量时从自身函数定义开始,从“内”向“外”查找。如果内部函数定义了与外部函数重名的变量,则内部函数的变量将“屏蔽”外部函数的变量。

变量提升

JavaScript的函数定义有个特点,它会先扫描整个函数体的语句,把所有用var申明的变量“提升”到函数顶部:

1
2
3
4
5
6
7
function foo() {
var x = 'Hello, ' + y;
console.log(x);
var y = 'Bob';
}

foo();

虽然是strict模式,但语句var x = 'Hello, ' + y;并不报错,原因是变量y在稍后申明了。但是console.log显示Hello, undefined,说明变量y的值为undefined。这正是因为JavaScript引擎自动提升了变量y的声明,但不会提升变量y的赋值。

对于上述foo()函数,JavaScript引擎看到的代码相当于:

1
2
3
4
5
6
function foo() {
var y; // 提升变量y的申明,此时y为undefined
var x = 'Hello, ' + y;
console.log(x);
y = 'Bob';
}

由于JavaScript的这一怪异的“特性”,我们在函数内部定义变量时,请严格遵守“在函数内部首先申明所有变量”这一规则。最常见的做法是用一个var申明函数内部用到的所有变量:

1
2
3
4
5
6
7
8
9
10
function foo() {
var
x = 1, // x初始化为1
y = x + 1, // y初始化为2
z, i; // z和i为undefined
// 其他语句:
for (i=0; i<100; i++) {
...
}
}

如果不需要兼容低版本浏览器,完全可以用let代替var来申明变量。

注意

建议使用let申明变量,避免var申明变量时带来的隐患。

全局作用域

不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性:

1
2
3
var course = 'Learn JavaScript';
console.log(course); // 'Learn JavaScript'
console.log(window.course); // 'Learn JavaScript'

因此,直接访问全局变量course和访问window.course是完全一样的。

你可能猜到了,由于函数定义有两种方式,以变量方式var foo = function () {}定义的函数实际上也是一个全局变量,因此,顶层函数的定义也被视为一个全局变量,并绑定到window对象:

1
2
3
4
5
6
function foo() {
alert('foo');
}

foo(); // 直接调用foo()
window.foo(); // 通过window.foo()调用

进一步大胆地猜测,我们每次直接调用的alert()函数其实也是window的一个变量:

1
2
3
4
5
6
7
8
9
10
11
window.alert('调用window.alert()');
// 把alert保存到另一个变量:
let old_alert = window.alert;
// 给alert赋一个新函数:
window.alert = function () {}

alert('无法用alert()显示了!');

// 恢复alert:
window.alert = old_alert;
alert('又可以用alert()了!');

这说明JavaScript实际上只有一个全局作用域。任何变量(函数也视为变量),如果没有在当前函数作用域中找到,就会继续往上查找,最后如果在全局作用域中也没有找到,则报ReferenceError错误。

名字空间

全局变量会绑定到window上,不同的JavaScript文件如果使用了相同的全局变量,或者定义了相同名字的顶层函数,都会造成命名冲突,并且很难被发现。

减少冲突的一个方法是把自己的所有变量和函数全部绑定到一个全局变量中。例如:

1
2
3
4
5
6
7
8
9
10
11
// 唯一的全局变量MYAPP:
let MYAPP = {};

// 其他变量:
MYAPP.name = 'myapp';
MYAPP.version = 1.0;

// 其他函数:
MYAPP.foo = function () {
return 'foo';
};

把自己的代码全部放入唯一的名字空间MYAPP中,会大大减少全局变量冲突的可能。

许多著名的JavaScript库都是这么干的:jQuery,YUI,underscore等等。

局部作用域

由于JavaScript的变量作用域实际上是函数内部,我们在for循环等语句块中是无法定义具有局部作用域的变量的:

1
2
3
4
5
6
function foo() {
for (var i=0; i<100; i++) {
//
}
i += 100; // 仍然可以引用变量i
}

为了解决块级作用域,ES6引入了新的关键字let,用let替代var可以申明一个块级作用域的变量:

1
2
3
4
5
6
7
8
function foo() {
let sum = 0;
for (let i=0; i<100; i++) {
sum += i;
}
// SyntaxError:
i += 1;
}

常量

由于varlet申明的是变量,如果要申明一个常量,在ES6之前是不行的,我们通常用全部大写的变量来表示“这是一个常量,不要修改它的值”:

1
let PI = 3.14;

ES6标准引入了新的关键字const来定义常量,constlet都具有块级作用域:

1
2
3
const PI = 3.14;
PI = 3; // 某些浏览器不报错,但是无效果!
PI; // 3.14

解构赋值

从ES6开始,JavaScript引入了解构赋值,可以同时对一组变量进行赋值。

什么是解构赋值?我们先看看传统的做法,如何把一个数组的元素分别赋值给几个变量:

1
2
3
4
let array = ['hello', 'JavaScript', 'ES6'];
let x = array[0];
let y = array[1];
let z = array[2];

现在,在ES6中,可以使用解构赋值,直接对多个变量同时赋值:

1
2
3
4
5
// 如果浏览器支持解构赋值就不会报错:
let [x, y, z] = ['hello', 'JavaScript', 'ES6'];

// x, y, z分别被赋值为数组对应元素:
console.log(`x = ${x}, y = ${y}, z = ${z}`);

注意,对数组元素进行解构赋值时,多个变量要用[...]括起来。

如果数组本身还有嵌套,也可以通过下面的形式进行解构赋值,注意嵌套层次和位置要保持一致:

1
2
3
4
let [x, [y, z]] = ['hello', ['JavaScript', 'ES6']];
x; // 'hello'
y; // 'JavaScript'
z; // 'ES6'

解构赋值还可以忽略某些元素:

1
2
let [, , z] = ['hello', 'JavaScript', 'ES6']; // 忽略前两个元素,只对z赋值第三个元素
z; // 'ES6'

如果需要从一个对象中取出若干属性,也可以使用解构赋值,便于快速获取对象的指定属性:

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school'
};
let {name, age, passport} = person;

// name, age, passport分别被赋值为对应属性:
console.log(`name = ${name}, age = ${age}, passport = ${passport}`);

对一个对象进行解构赋值时,同样可以直接对嵌套的对象属性进行赋值,只要保证对应的层次是一致的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school',
address: {
city: 'Beijing',
street: 'No.1 Road',
zipcode: '100001'
}
};
let {name, address: {city, zip}} = person;
name; // '小明'
city; // 'Beijing'
zip; // undefined, 因为属性名是zipcode而不是zip
// 注意: address不是变量,而是为了让city和zip获得嵌套的address对象的属性:
address; // Uncaught ReferenceError: address is not defined

使用解构赋值对对象属性进行赋值时,如果对应的属性不存在,变量将被赋值为undefined,这和引用一个不存在的属性获得undefined是一致的。如果要使用的变量名和属性名不一致,可以用下面的语法获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school'
};

// 把passport属性赋值给变量id:
let {name, passport:id} = person;
name; // '小明'
id; // 'G-12345678'
// 注意: passport不是变量,而是为了让变量id获得passport属性:
passport; // Uncaught ReferenceError: passport is not defined

解构赋值还可以使用默认值,这样就避免了不存在的属性返回undefined的问题:

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678'
};

// 如果person对象没有single属性,默认赋值为true:
let {name, single=true} = person;
name; // '小明'
single; // true

有些时候,如果变量已经被声明了,再次赋值的时候,正确的写法也会报语法错误:

1
2
3
4
5
// 声明变量:
let x, y;
// 解构赋值:
{x, y} = { name: '小明', x: 100, y: 200};
// 语法错误: Uncaught SyntaxError: Unexpected token =

这是因为JavaScript引擎把{开头的语句当作了块处理,于是=不再合法。解决方法是用小括号括起来:

1
({x, y} = { name: '小明', x: 100, y: 200});

使用场景

解构赋值在很多时候可以大大简化代码。例如,交换两个变量xy的值,可以这么写,不再需要临时变量:

1
2
let x=1, y=2;
[x, y] = [y, x]

快速获取当前页面的域名和路径:

1
let {hostname:domain, pathname:path} = location;

如果一个函数接收一个对象作为参数,那么,可以使用解构直接把对象的属性绑定到变量中。例如,下面的函数可以快速创建一个Date对象:

1
2
3
function buildDate({year, month, day, hour=0, minute=0, second=0}) {
return new Date(`${year}-${month}-${day} ${hour}:${minute}:${second}`);
}

它的方便之处在于传入的对象只需要yearmonthday这三个属性:

1
2
buildDate({ year: 2017, month: 1, day: 1 });
// Sun Jan 01 2017 00:00:00 GMT+0800 (CST)

也可以传入hourminutesecond属性:

1
2
buildDate({ year: 2017, month: 1, day: 1, hour: 20, minute: 15 });
// Sun Jan 01 2017 20:15:00 GMT+0800 (CST)

使用解构赋值可以减少代码量,但是,需要在支持ES6解构赋值特性的现代浏览器中才能正常运行。目前支持解构赋值的浏览器包括Chrome,Firefox,Edge等。

在一个对象中绑定函数,称为这个对象的方法。

在JavaScript中,对象的定义是这样的:

1
2
3
4
let xiaoming = {
name: '小明',
birth: 1990
};

但是,如果我们给xiaoming绑定一个函数,就可以做更多的事情。比如,写个age()方法,返回xiaoming的年龄:

1
2
3
4
5
6
7
8
9
10
11
let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let y = new Date().getFullYear();
return y - this.birth;
}
};

xiaoming.age; // function xiaoming.age()
xiaoming.age(); // 今年调用是25,明年调用就变成26了

绑定到对象上的函数称为方法,和普通函数也没啥区别,但是它在内部使用了一个this关键字,这个东东是什么?

在一个方法内部,this是一个特殊变量,它始终指向当前对象,也就是xiaoming这个变量。所以,this.birth可以拿到xiaomingbirth属性。

让我们拆开写:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getAge() {
let y = new Date().getFullYear();
return y - this.birth;
}

let xiaoming = {
name: '小明',
birth: 1990,
age: getAge
};

xiaoming.age(); // 25, 正常结果
getAge(); // NaN

单独调用函数getAge()怎么返回了NaN请注意,我们已经进入到了JavaScript的一个大坑里。

JavaScript的函数内部如果调用了this,那么这个this到底指向谁?

答案是,视情况而定!

如果以对象的方法形式调用,比如xiaoming.age(),该函数的this指向被调用的对象,也就是xiaoming,这是符合我们预期的。

如果单独调用函数,比如getAge(),此时,该函数的this指向全局对象,也就是window

坑爹啊!

更坑爹的是,如果这么写:

1
2
let fn = xiaoming.age; // 先拿到xiaoming的age函数
fn(); // NaN

也是不行的!要保证this指向正确,必须用obj.xxx()的形式调用!

由于这是一个巨大的设计错误,要想纠正可没那么简单。ECMA决定,在strict模式下让函数的this指向undefined,因此,在strict模式下,你会得到一个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let y = new Date().getFullYear();
return y - this.birth;
}
};

let fn = xiaoming.age;
fn(); // Uncaught TypeError: Cannot read property 'birth' of undefined

这个决定只是让错误及时暴露出来,并没有解决this应该指向的正确位置。

有些时候,喜欢重构的你把方法重构了一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
function getAgeFromBirth() {
let y = new Date().getFullYear();
return y - this.birth;
}
return getAgeFromBirth();
}
};

xiaoming.age(); // Uncaught TypeError: Cannot read property 'birth' of undefined

结果又报错了!原因是this指针只在age方法的函数内指向xiaoming,在函数内部定义的函数,this又指向undefined了!(在非strict模式下,它重新指向全局对象window!)

修复的办法也不是没有,我们用一个that变量首先捕获this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let that = this; // 在方法内部一开始就捕获this
function getAgeFromBirth() {
let y = new Date().getFullYear();
return y - that.birth; // 用that而不是this
}
return getAgeFromBirth();
}
};

xiaoming.age(); // 25

let that = this;,你就可以放心地在方法内部定义其他函数,而不是把所有语句都堆到一个方法中。

apply

虽然在一个独立的函数调用中,根据是否是strict模式,this指向undefinedwindow,不过,我们还是可以控制this的指向的!

要指定函数的this指向哪个对象,可以用函数本身的apply方法,它接收两个参数,第一个参数就是需要绑定的this变量,第二个参数是Array,表示函数本身的参数。

apply修复getAge()调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getAge() {
let y = new Date().getFullYear();
return y - this.birth;
}

let xiaoming = {
name: '小明',
birth: 1990,
age: getAge
};

xiaoming.age(); // 25
getAge.apply(xiaoming, []); // 25, this指向xiaoming, 参数为空

另一个与apply()类似的方法是call(),唯一区别是:

  • apply()把参数打包成Array再传入;
  • call()把参数按顺序传入。

比如调用Math.max(3, 5, 4),分别用apply()call()实现如下:

1
2
Math.max.apply(null, [3, 5, 4]); // 5
Math.max.call(null, 3, 5, 4); // 5

对普通函数调用,我们通常把this绑定为null

装饰器

利用apply(),我们还可以动态改变函数的行为。

JavaScript的所有对象都是动态的,即使内置的函数,我们也可以重新指向新的函数。

现在假定我们想统计一下代码一共调用了多少次parseInt(),可以把所有的调用都找出来,然后手动加上count += 1,不过这样做太傻了。最佳方案是用我们自己的函数替换掉默认的parseInt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';

let count = 0;
let oldParseInt = parseInt; // 保存原函数

window.parseInt = function () {
count += 1;
return oldParseInt.apply(null, arguments); // 调用原函数
};

// 测试:
parseInt('10');
parseInt('20');
parseInt('30');
console.log('count = ' + count); // 3

高阶函数英文叫Higher-order function。那么什么是高阶函数?

JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。

一个最简单的高阶函数:

1
2
3
function add(x, y, f) {
return f(x) + f(y);
}

当我们调用add(-5, 6, Math.abs)时,参数xyf分别接收-56和函数Math.abs,根据函数定义,我们可以推导计算过程为:

1
2
3
4
5
x = -5;
y = 6;
f = Math.abs;
f(x) + f(y) ==> Math.abs(-5) + Math.abs(6) ==> 11;
return 11;

用代码验证一下:

1
2
3
4
5
6
function add(x, y, f) {
return f(x) + f(y);
}

let x = add(-5, 6, Math.abs);
console.log(x); // 11

编写高阶函数,就是让函数的参数能够接收别的函数。

如果你读过Google的那篇大名鼎鼎的论文“MapReduce: Simplified Data Processing on Large Clusters”,你就能大概明白map/reduce的概念。

map

举例说明,比如我们有一个函数f(x)=x2,要把这个函数作用在一个数组[1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
            f(x) = x * x



┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 2 3 4 5 6 7 8 9 ]

│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 4 9 16 25 36 49 64 81 ]

由于map()方法定义在JavaScript的Array中,我们调用Arraymap()方法,传入我们自己的函数,就得到了一个新的Array作为结果:

1
2
3
4
5
6
7
function pow(x) {
return x * x;
}

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let results = arr.map(pow); // [1, 4, 9, 16, 25, 36, 49, 64, 81]
console.log(results);

注意:map()传入的参数是pow,即函数对象本身。

你可能会想,不需要map(),写一个循环,也可以计算出结果:

1
2
3
4
5
6
7
8
9
let f = function (x) {
return x * x;
};

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let result = [];
for (let i=0; i<arr.length; i++) {
result.push(f(arr[i]));
}

的确可以,但是,从上面的循环代码,我们无法一眼看明白“把f(x)作用在Array的每一个元素并把结果生成一个新的Array”。

所以,map()作为高阶函数,事实上它把运算规则抽象了,因此,我们不但可以计算简单的f(x)=x2,还可以计算任意复杂的函数,比如,把Array的所有数字转为字符串:

1
2
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
arr.map(String); // ['1', '2', '3', '4', '5', '6', '7', '8', '9']

只需要一行代码。

reduce

再看reduce的用法。Array的reduce()把一个函数作用在这个Array[x1, x2, x3...]上,这个函数必须接收两个参数,reduce()把结果继续和序列的下一个元素做累积计算,其效果就是:

1
[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

比方说对一个Array求和,就可以用reduce实现:

1
2
3
4
let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
return x + y;
}); // 25

如果数组元素只有1个,那么还需要提供一个额外的初始参数以便至少凑够两:

1
2
3
4
let arr = [123];
arr.reduce(function (x, y) {
return x + y;
}, 0); // 123

练习:利用reduce()求积:

1
2
3
4
5
6
7
8
9
10
11
12
function product(arr) {
// FIXME:
return 0;
}

// 测试:
if (product([1, 2, 3, 4]) === 24 && product([0, 1, 2]) === 0 && product([99, 88, 77, 66]) === 44274384) {
console.log('测试通过!');
}
else {
console.log('测试失败!');
}

要把[1, 3, 5, 7, 9]变换成整数13579,reduce()也能派上用场:

1
2
3
4
let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
return x * 10 + y;
}); // 13579

如果我们继续改进这个例子,想办法把一个字符串13579先变成Array——[1, 3, 5, 7, 9],再利用reduce()就可以写出一个把字符串转换为Number的函数。

练习:不要使用JavaScript内置的parseInt()函数,利用map和reduce操作实现一个string2int()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function string2int(s) {
// FIXME:
return 0;
}

// 测试:
if (string2int('0') === 0 && string2int('12345') === 12345 && string2int('12300') === 12300) {
if (string2int.toString().indexOf('parseInt') !== -1) {
console.log('请勿使用parseInt()!');
} else if (string2int.toString().indexOf('Number') !== -1) {
console.log('请勿使用Number()!');
} else {
console.log('测试通过!');
}
}
else {
console.log('测试失败!');
}

练习

请把用户输入的不规范的英文名字,变为首字母大写,其他小写的规范名字。输入:['adam', 'LISA', 'barT'],输出:['Adam', 'Lisa', 'Bart']

1
2
3
4
5
6
7
8
9
10
11
12
function normalize(arr) {
// FIXME:
return [];
}

// 测试:
if (normalize(['adam', 'LISA', 'barT']).toString() === ['Adam', 'Lisa', 'Bart'].toString()) {
console.log('测试通过!');
}
else {
console.log('测试失败!');
}

小明希望利用map()把字符串变成整数,他写的代码很简洁:

1
2
3
4
5
let arr = ['1', '2', '3'];
let r;

r = arr.map(parseInt);
console.log(r);

结果竟然是1, NaN, NaN,小明百思不得其解,请帮他找到原因并修正代码。

提示:参考Array.prototype.map()的文档

原因分析:

由于map()接收的回调函数可以有3个参数:callback(currentValue, index, array),通常我们仅需要第一个参数,而忽略了传入的后面两个参数。不幸的是,parseInt(string, radix)没有忽略第二个参数,导致实际执行的函数分别是:

parseInt(‘1’, 0); // 1, 按十进制转换
parseInt(‘2’, 1); // NaN, 没有一进制
parseInt(‘3’, 2); // NaN, 按二进制转换不允许出现3
可以改为r = arr.map(Number);,因为Number(value)函数仅接收一个参数。

filter也是一个常用的操作,它用于把Array的某些元素过滤掉,然后返回剩下的元素。

map()类似,Arrayfilter()也接收一个函数。和map()不同的是,filter()把传入的函数依次作用于每个元素,然后根据返回值是true还是false决定保留还是丢弃该元素。

例如,在一个Array中,删掉偶数,只保留奇数,可以这么写:

1
2
3
4
5
let arr = [1, 2, 4, 5, 6, 9, 10, 15];
let r = arr.filter(function (x) {
return x % 2 !== 0;
});
r; // [1, 5, 9, 15]

把一个Array中的空字符串删掉,可以这么写:

1
2
3
4
5
let arr = ['A', '', 'B', null, undefined, 'C', '  '];
let r = arr.filter(function (s) {
return s && s.trim(); // 注意:IE9以下的版本没有trim()方法
});
r; // ['A', 'B', 'C']

可见用filter()这个高阶函数,关键在于正确实现一个“筛选”函数。

回调函数

filter()接收的回调函数,其实可以有多个参数。通常我们仅使用第一个参数,表示Array的某个元素。回调函数还可以接收另外两个参数,表示元素的位置和数组本身:

1
2
3
4
5
6
7
let arr = ['A', 'B', 'C'];
let r = arr.filter(function (element, index, self) {
console.log(element); // 依次打印'A', 'B', 'C'
console.log(index); // 依次打印0, 1, 2
console.log(self); // self就是变量arr
return true;
});

利用filter,可以巧妙地去除Array的重复元素:

1
2
3
4
5
6
7
8
9
let
r,
arr = ['apple', 'strawberry', 'banana', 'pear', 'apple', 'orange', 'orange', 'strawberry'];

r = arr.filter(function (element, index, self) {
return self.indexOf(element) === index;
});

console.log(r);

去除重复元素依靠的是indexOf总是返回第一个元素的位置,后续的重复元素位置与indexOf返回的位置不相等,因此被filter滤掉了。

练习

请尝试用filter()筛选出素数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function get_primes(arr) {
// FIXME:
return [];
}

// 测试:
let
x,
r,
arr = [];
for (x = 1; x < 100; x++) {
arr.push(x);
}
r = get_primes(arr);
if (r.toString() === [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97].toString()) {
console.log('测试通过!');
} else {
console.log('测试失败: ' + r.toString());
}

排序算法

排序也是在程序中经常用到的算法。无论使用冒泡排序还是快速排序,排序的核心是比较两个元素的大小。如果是数字,我们可以直接比较,但如果是字符串或者两个对象呢?直接比较数学上的大小是没有意义的,因此,比较的过程必须通过函数抽象出来。通常规定,对于两个元素xy,如果认为x < y,则返回-1,如果认为x == y,则返回0,如果认为x > y,则返回1,这样,排序算法就不用关心具体的比较过程,而是根据比较结果直接排序。

JavaScript的Arraysort()方法就是用于排序的,但是排序结果可能让你大吃一惊:

1
2
3
4
5
6
7
8
// 看上去正常的结果:
['Google', 'Apple', 'Microsoft'].sort(); // ['Apple', 'Google', 'Microsoft'];

// apple排在了最后:
['Google', 'apple', 'Microsoft'].sort(); // ['Google', 'Microsoft", 'apple']

// 无法理解的结果:
[10, 20, 1, 2].sort(); // [1, 10, 2, 20]

第二个排序把apple排在了最后,是因为字符串根据ASCII码进行排序,而小写字母a的ASCII码在大写字母之后。

第三个排序结果是什么鬼?简单的数字排序都能错?

这是因为Arraysort()方法默认把所有元素先转换为String再排序,结果'10'排在了'2'的前面,因为字符'1'比字符'2'的ASCII码小。

douwo

如果不知道sort()方法的默认排序规则,直接对数字排序,绝对栽进坑里!

幸运的是,sort()方法也是一个高阶函数,它还可以接收一个比较函数来实现自定义的排序。

要按数字大小排序,我们可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
let arr = [10, 20, 1, 2];

arr.sort(function (x, y) {
if (x < y) {
return -1;
}
if (x > y) {
return 1;
}
return 0;
});

console.log(arr); // [1, 2, 10, 20]

如果要倒序排序,我们可以把大的数放前面:

1
2
3
4
let arr = [10, 20, 1, 2];
arr.sort(function (x, y) {
return y - x;
}); // [20, 10, 2, 1]

sort()传入的比较函数接受xy两个参数,如果x<y,应返回负数,如果x>y,应返回正数,如果x=y,应返回0。

默认情况下,对字符串排序,是按照ASCII的大小比较的,现在,我们提出排序应该忽略大小写,按照字母序排序。要实现这个算法,不必对现有代码大加改动,只要我们能定义出忽略大小写的比较算法就可以:

1
2
3
4
5
6
7
8
9
10
11
12
let arr = ['Google', 'apple', 'Microsoft'];
arr.sort(function (s1, s2) {
x1 = s1.toUpperCase();
x2 = s2.toUpperCase();
if (x1 < x2) {
return -1;
}
if (x1 > x2) {
return 1;
}
return 0;
}); // ['apple', 'Google', 'Microsoft']

忽略大小写来比较两个字符串,实际上就是先把字符串都变成大写(或者都变成小写),再比较。

从上述例子可以看出,高阶函数的抽象能力是非常强大的,而且,核心代码可以保持得非常简洁。

最后友情提示,sort()方法会直接对Array进行修改,它返回的结果仍是当前Array

1
2
3
4
5
let a1 = ['B', 'A', 'C'];
let a2 = a1.sort();
a1; // ['A', 'B', 'C']
a2; // ['A', 'B', 'C']
a1 === a2; // true, a1和a2是同一对象

Array

对于数组,除了map()reducefilter()sort()这些方法可以传入一个函数外,Array对象还提供了很多非常实用的高阶函数。

every

every()方法可以判断数组的所有元素是否满足测试条件。

例如,给定一个包含若干字符串的数组,判断所有字符串是否满足指定的测试条件:

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.every(function (s) {
return s.length > 0;
})); // true, 因为每个元素都满足s.length>0

console.log(arr.every(function (s) {
return s.toLowerCase() === s;
})); // false, 因为不是每个元素都全部是小写

find

find()方法用于查找符合条件的第一个元素,如果找到了,返回这个元素,否则,返回undefined

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.find(function (s) {
return s.toLowerCase() === s;
})); // 'pear', 因为pear全部是小写

console.log(arr.find(function (s) {
return s.toUpperCase() === s;
})); // undefined, 因为没有全部是大写的元素

findIndex

findIndex()find()类似,也是查找符合条件的第一个元素,不同之处在于findIndex()会返回这个元素的索引,如果没有找到,返回-1

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.findIndex(function (s) {
return s.toLowerCase() === s;
})); // 1, 因为'pear'的索引是1

console.log(arr.findIndex(function (s) {
return s.toUpperCase() === s;
})); // -1

forEach

forEach()map()类似,它也把每个元素依次作用于传入的函数,但不会返回新的数组。forEach()常用于遍历数组,因此,传入的函数不需要返回值:

1
2
let arr = ['Apple', 'pear', 'orange'];
arr.forEach(x=>console.log(x)); // 依次打印每个元素


函数作为返回值

高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回。

我们来实现一个对Array的求和。通常情况下,求和的函数是这样定义的:

1
2
3
4
5
6
7
function sum(arr) {
return arr.reduce(function (x, y) {
return x + y;
});
}

sum([1, 2, 3, 4, 5]); // 15

但是,如果不需要立刻求和,而是在后面的代码中,根据需要再计算怎么办?可以不返回求和的结果,而是返回求和的函数!

1
2
3
4
5
6
7
8
function lazy_sum(arr) {
let sum = function () {
return arr.reduce(function (x, y) {
return x + y;
});
}
return sum;
}

当我们调用lazy_sum()时,返回的并不是求和结果,而是求和函数:

1
let f = lazy_sum([1, 2, 3, 4, 5]); // function sum()

调用函数f时,才真正计算求和的结果:

1
f(); // 15

在这个例子中,我们在函数lazy_sum中又定义了函数sum,并且,内部函数sum可以引用外部函数lazy_sum的参数和局部变量,当lazy_sum返回函数sum时,相关参数和变量都保存在返回的函数中,这种称为“闭包(Closure)”的程序结构拥有极大的威力。

请再注意一点,当我们调用lazy_sum()时,每次调用都会返回一个新的函数,即使传入相同的参数:

1
2
3
let f1 = lazy_sum([1, 2, 3, 4, 5]);
let f2 = lazy_sum([1, 2, 3, 4, 5]);
f1 === f2; // false

f1()f2()的调用结果互不影响。

闭包

注意到返回的函数在其定义内部引用了局部变量arr,所以,当一个函数返回了一个函数后,其内部的局部变量还被新函数引用,所以,闭包用起来简单,实现起来可不容易。

另一个需要注意的问题是,返回的函数并没有立刻执行,而是直到调用了f()才执行。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
function count() {
let arr = [];
for (var i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

let results = count();
let [f1, f2, f3] = results;

在上面的例子中,每次循环,都创建了一个新的函数,然后,把创建的3个函数都添加到一个Array中返回了。

你可能认为调用f1()f2()f3()结果应该是149,但实际结果是:

1
2
3
f1(); // 16
f2(); // 16
f3(); // 16

全部都是16!原因就在于返回的函数引用了用var定义的变量i,但它并非立刻执行。等到3个函数都返回时,它们所引用的变量i已经变成了4,因此最终结果为16

返回闭包时牢记的一点就是:返回函数不要引用任何循环变量,或者后续会发生变化的变量。

如果一定要引用循环变量怎么办?方法是再创建一个函数,用该函数的参数绑定循环变量当前的值,无论该循环变量后续如何更改,已绑定到函数参数的值不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function count() {
let arr = [];
for (var i=1; i<=3; i++) {
arr.push((function (n) {
return function () {
return n * n;
}
})(i));
}
return arr;
}

let [f1, f2, f3] = count();

f1(); // 1
f2(); // 4
f3(); // 9

注意这里用了一个“创建一个匿名函数并立刻执行”的语法:

1
2
3
(function (x) {
return x * x;
})(3); // 9

理论上讲,创建一个匿名函数并立刻执行可以这么写:

1
function (x) { return x * x } (3);

但是由于JavaScript语法解析的问题,会报SyntaxError错误,因此需要用括号把整个函数定义括起来:

1
(function (x) { return x * x }) (3);

通常,一个立即执行的匿名函数可以把函数体拆开,一般这么写:

1
2
3
(function (x) {
return x * x;
})(3);

另一个方法是把循环变量ilet定义在for循环体中,let作用域决定了在每次循环时都会绑定新的i

1
2
3
4
5
6
7
8
9
function count() {
let arr = [];
for (let i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

但如果i定义在for循环外面,则仍然是错误的:

1
2
3
4
5
6
7
8
9
10
function count() {
let arr = [];
let i;
for (i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

因此,最好的办法还是返回函数不要引用任何循环变量。

说了这么多,难道闭包就是为了返回一个函数然后延迟执行吗?

当然不是!闭包有非常强大的功能。举个栗子:

在面向对象的程序设计语言里,比如Java和C++,要在对象内部封装一个私有变量,可以用private修饰一个成员变量。

在没有class机制,只有函数的语言里,借助闭包,同样可以封装一个私有变量。我们用JavaScript创建一个计数器:

1
2
3
4
5
6
7
8
9
function create_counter(initial) {
let x = initial || 0;
return {
inc: function () {
x += 1;
return x;
}
}
}

它用起来像这样:

1
2
3
4
5
6
7
8
9
let c1 = create_counter();
c1.inc(); // 1
c1.inc(); // 2
c1.inc(); // 3

let c2 = create_counter(10);
c2.inc(); // 11
c2.inc(); // 12
c2.inc(); // 13

在返回的对象中,实现了一个闭包,该闭包携带了局部变量x,并且,从外部代码根本无法访问到变量x。换句话说,闭包就是携带状态的函数,并且它的状态可以完全对外隐藏起来。

闭包还可以把多参数的函数变成单参数的函数。例如,要计算xy可以用Math.pow(x, y)函数,不过考虑到经常计算x2或x3,我们可以利用闭包创建新的函数pow2pow3

1
2
3
4
5
6
7
8
9
10
11
12
function make_pow(n) {
return function (x) {
return Math.pow(x, n);
}
}

// 创建两个新函数:
let pow2 = make_pow(2);
let pow3 = make_pow(3);

console.log(pow2(5)); // 25
console.log(pow3(7)); // 343

廖雪峰
资深软件开发工程师,业余马拉松选手。

ES6标准新增了一种新的函数:箭头函数(Arrow Function)。

为什么叫箭头函数?因为它的定义用的就是一个箭头:

1
x => x * x

上面的箭头函数相当于:

1
2
3
function (x) {
return x * x;
}

在继续学习箭头函数之前,请测试你的浏览器是否支持ES6的Arrow Function:

1
2
let fn = x => x * x;
console.log('你的浏览器支持ES6的Arrow Function!');

箭头函数相当于匿名函数,并且简化了函数定义。箭头函数有两种格式,一种像上面的,只包含一个表达式,连{ ... }return都省略掉了。还有一种可以包含多条语句,这时候就不能省略{ ... }return

1
2
3
4
5
6
7
8
x => {
if (x > 0) {
return x * x;
}
else {
return - x * x;
}
}

如果参数不是一个,就需要用括号()括起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 两个参数:
(x, y) => x * x + y * y

// 无参数:
() => 3.14

// 可变参数:
(x, y, ...rest) => {
let i, sum = x + y;
for (i=0; i<rest.length; i++) {
sum += rest[i];
}
return sum;
}

如果要返回一个对象,就要注意,如果是单表达式,这么写的话会报错:

1
2
// SyntaxError:
x => { foo: x }

因为和函数体的{ ... }有语法冲突,所以要改为:

1
2
// ok:
x => ({ foo: x })

this

箭头函数看上去是匿名函数的一种简写,但实际上,箭头函数和匿名函数有个明显的区别:箭头函数内部的this是词法作用域,由上下文确定。

回顾前面的例子,由于JavaScript函数对this绑定的错误处理,下面的例子无法得到预期结果:

1
2
3
4
5
6
7
8
9
10
let obj = {
birth: 1990,
getAge: function () {
let b = this.birth; // 1990
let fn = function () {
return new Date().getFullYear() - this.birth; // this指向window或undefined
};
return fn();
}
};

现在,箭头函数完全修复了this的指向,this总是指向词法作用域,也就是外层调用者obj

1
2
3
4
5
6
7
8
9
let obj = {
birth: 1990,
getAge: function () {
let b = this.birth; // 1990
let fn = () => new Date().getFullYear() - this.birth; // this指向obj对象
return fn();
}
};
obj.getAge(); // 25

如果使用箭头函数,以前的那种hack写法:

1
let that = this;

就不再需要了。

由于this在箭头函数中已经按照词法作用域绑定了,所以,用call()或者apply()调用箭头函数时,无法对this进行绑定,即传入的第一个参数被忽略:

1
2
3
4
5
6
7
8
9
let obj = {
birth: 1990,
getAge: function (year) {
let b = this.birth; // 1990
let fn = (y) => y - this.birth; // this.birth仍是1990
return fn.call({birth:2000}, year);
}
};
obj.getAge(2015); // 25

练习

请使用箭头函数简化排序时传入的函数:

1
2
3
4
5
let arr = [10, 20, 1, 2];
arr.sort((x, y) => {
???
});
console.log(arr); // [1, 2, 10, 20]

标签函数

廖雪峰
资深软件开发工程师,业余马拉松选手。

前面我们介绍了模板字符串,它可以非常方便地引用变量,并合并出最终的字符串:

1
2
3
4
let name = '小明';
let age = 20;
let s = `你好, ${name}, 你今年${age}岁了!`;
console.log(s);

对于模板字符串,除了方便引用变量构造字符串外,还有一种更强大的功能,即可以使用标签函数(Tag Function)。

什么是标签函数?让我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const email = "test@example.com";
const password = 'hello123';

function sql(strings, ...exps) {
console.log(`SQL: ${strings.join('?')}`);
console.log(`SQL parameters: ${JSON.stringify(exps)}`);
return {
name: '小明',
age: 20
};
}

const result = sql`SELECT * FROM users WHERE email=${email} AND password=${password}`;

console.log(JSON.stringify(result));

这里出现了一个奇怪的语法:

1
sql`SELECT * FROM users WHERE email=${email} AND password=${password}`

模板字符串前面以sql开头,实际上这是一个标签函数,上述语法会自动转换为对sql()函数的调用。我们关注的是,传入sql()函数的参数是什么。

sql()函数实际上接收两个参数:

第一个参数strings是一个字符串数组,它是["SELECT * FROM users WHERE email=", " AND password=", ""],即除去${xxx}剩下的字符组成的数组;

第二个参数...exps是一个可变参数,它接收的也是一个数组,但数组的内容是由模板字符串里所有的${xxx}的实际值组成,即["test@example.com", "hello123"],因为解析${email}得到"test@example.com",解析${password}得到"hello123"

标签函数sql()实际上是一个普通函数,我们在内部把strings拼接成一个SQL字符串,把...exps作为参数,就可以实现一个安全的SQL查询,并返回查询结果。此处并没有真正的数据库连接,因此返回一个固定的Object。

标签函数和普通函数的定义区别仅仅在于参数,如果我们想对数据库进行修改,完全可以定义一个标签函数如下:

1
2
3
4
5
function update(strings, ...exps) {
let sql = strings.join('?');
// 执行数据库更新
// TODO:
}

函数调用可以简化为带标签的模板字符串:

1
2
3
4
5
let id = 123;
let age = 21;
let score = 'A';

update`UPDATE users SET age=${age}, score=${score} WHERE id=${id}`;

是不是非常简洁?



廖雪峰
资深软件开发工程师,业余马拉松选手。

生成器(generator)是ES6标准引入的新的数据类型。一个生成器看上去像一个函数,但可以返回多次。

ES6定义生成器标准的哥们借鉴了Python的generator的概念和语法,如果你对Python的generator很熟悉,那么ES6的generator就是小菜一碟了。如果你对Python还不熟,赶快恶补Python教程!。

我们先复习函数的概念。一个函数是一段完整的代码,调用一个函数就是传入参数,然后返回结果:

1
2
3
4
5
function foo(x) {
return x + x;
}

let r = foo(1); // 调用foo函数

函数在执行过程中,如果没有遇到return语句(函数末尾如果没有return,就是隐含的return undefined;),控制权无法交回被调用的代码。

generator跟函数很像,定义如下:

1
2
3
4
5
function* foo(x) {
yield x + 1;
yield x + 2;
return x + 3;
}

generator和函数不同的是,generator由function*定义(注意多出的*号),并且,除了return语句,还可以用yield返回多次。

大多数同学立刻就晕了,generator就是能够返回多次的“函数”?返回多次有啥用?

还是举个栗子吧。

我们以一个著名的斐波那契数列为例,它由01开头:

1
0 1 1 2 3 5 8 13 21 34 ...

要编写一个产生斐波那契数列的函数,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fib(max) {
let
t,
a = 0,
b = 1,
arr = [0, 1];
while (arr.length < max) {
[a, b] = [b, a + b];
arr.push(b);
}
return arr;
}

// 测试:
fib(5); // [0, 1, 1, 2, 3]
fib(10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

函数只能返回一次,所以必须返回一个Array。但是,如果换成generator,就可以一次返回一个数,不断返回多次。用generator改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function* fib(max) {
let
t,
a = 0,
b = 1,
n = 0;
while (n < max) {
yield a;
[a, b] = [b, a + b];
n ++;
}
return;
}

直接调用试试:

1
fib(5); // fib {[[GeneratorStatus]]: "suspended", [[GeneratorReceiver]]: Window}

直接调用一个generator和调用函数不一样,fib(5)仅仅是创建了一个generator对象,还没有去执行它。

调用generator对象有两个方法,一是不断地调用generator对象的next()方法:

1
2
3
4
5
6
7
let f = fib(5);
f.next(); // {value: 0, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 2, done: false}
f.next(); // {value: 3, done: false}
f.next(); // {value: undefined, done: true}

next()方法会执行generator的代码,然后,每次遇到yield x;就返回一个对象{value: x, done: true/false},然后“暂停”。返回的value就是yield的返回值,done表示这个generator是否已经执行结束了。如果donetrue,则value就是return的返回值。

当执行到donetrue时,这个generator对象就已经全部执行完毕,不要再继续调用next()了。

第二个方法是直接用for ... of循环迭代generator对象,这种方式不需要我们自己判断done

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* fib(max) {
let
a = 0,
b = 1,
n = 0;
while (n < max) {
yield a;
[a, b] = [b, a + b];
n ++;
}
return;
}

for (let x of fib(10)) {
console.log(x); // 依次输出0, 1, 1, 2, 3, ...
}

generator和普通函数相比,有什么用?

因为generator可以在执行过程中多次返回,所以它看上去就像一个可以记住执行状态的函数,利用这一点,写一个generator就可以实现需要用面向对象才能实现的功能。例如,用一个对象来保存状态,得这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let fib = {
a: 0,
b: 1,
n: 0,
max: 5,
next: function () {
let
r = this.a,
t = this.a + this.b;
this.a = this.b;
this.b = t;
if (this.n < this.max) {
this.n ++;
return r;
} else {
return undefined;
}
}
};

用对象的属性来保存状态,相当繁琐。

generator还有另一个巨大的好处,就是把异步回调代码变成“同步”代码。这个好处要等到后面学了AJAX以后才能体会到。

没有generator之前的黑暗时代,用AJAX时需要这么写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ajax('http://url-1', data1, function (err, result) {
if (err) {
return handle(err);
}
ajax('http://url-2', data2, function (err, result) {
if (err) {
return handle(err);
}
ajax('http://url-3', data3, function (err, result) {
if (err) {
return handle(err);
}
return success(result);
});
});
});

回调越多,代码越难看。

有了generator的美好时代,用AJAX时可以这么写:

1
2
3
4
5
6
7
8
9
try {
r1 = yield ajax('http://url-1', data1);
r2 = yield ajax('http://url-2', data2);
r3 = yield ajax('http://url-3', data3);
success(r3);
}
catch (err) {
handle(err);
}

看上去是同步的代码,实际执行是异步的。

练习

要生成一个自增的ID,可以编写一个next_id()函数:

1
2
3
4
5
6
let current_id = 0;

function next_id() {
current_id ++;
return current_id;
}

由于函数无法保存状态,故需要一个全局变量current_id来保存数字。

不用闭包,试用generator改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function* next_id() {
???
}

// 测试:
let
x,
pass = true,
g = next_id();
for (x = 1; x < 100; x ++) {
if (g.next().value !== x) {
pass = false;
console.log('测试失败!');
break;
}
}
if (pass) {
console.log('测试通过!');
}

留言與分享

JavaScript-快速入门

分類 编程语言, JavaScript

JavaScript代码可以直接嵌在网页的任何地方,不过通常我们都把JavaScript代码放到<head>中:

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<script>
alert('Hello, world');
</script>
</head>
<body>
...
</body>
</html>

<script>...</script>包含的代码就是JavaScript代码,它将直接被浏览器执行。

第二种方法是把JavaScript代码放到一个单独的.js文件,然后在HTML中通过<script src="..."></script>引入这个文件:

1
2
3
4
5
6
7
8
<html>
<head>
<script src="/static/js/abc.js"></script>
</head>
<body>
...
</body>
</html>

这样,/static/js/abc.js就会被浏览器执行。

把JavaScript代码放入一个单独的.js文件中更利于维护代码,并且多个页面可以各自引用同一份.js文件。

可以在同一个页面中引入多个.js文件,还可以在页面中多次编写<script> js代码... </script>,浏览器按照顺序依次执行。

有些时候你会看到<script>标签还设置了一个type属性:

1
2
3
<script type="text/javascript">
...
</script>

但这是没有必要的,因为默认的type就是JavaScript,所以不必显式地把type指定为JavaScript。

如何编写JavaScript

可以用任何文本编辑器来编写JavaScript代码。这里我们推荐以下几种文本编辑器:

Visual Studio Code

微软出的Visual Studio Code,可以看做迷你版Visual Studio,免费,跨平台,内置JavaScript支持,强烈推荐使用!

Sublime Text

Sublime Text是一个好用的文本编辑器,免费,但不注册会不定时弹出提示框。

注意:不可以用Word或写字板来编写JavaScript或HTML,因为带格式的文本保存后不是纯文本文件,无法被浏览器正常读取。也尽量不要用记事本编写,它会自作聪明地在保存UTF-8格式文本时添加BOM头。

如何运行JavaScript

要让浏览器运行JavaScript,必须先有一个HTML页面,在HTML页面中引入JavaScript,然后,让浏览器加载该HTML页面,就可以执行JavaScript代码。

你也许会想,直接在我的硬盘上创建好HTML和JavaScript文件,然后用浏览器打开,不就可以看到效果了吗?

这种方式运行部分JavaScript代码没有问题,但由于浏览器的安全限制,以file://开头的地址无法执行如联网等JavaScript代码,最终,你还是需要架设一个Web服务器,然后以http://开头的地址来正常执行所有JavaScript代码。

不过,开始学习阶段,你无须关心如何搭建开发环境的问题,我们提供在页面输入JavaScript代码并直接运行的功能,让你专注于JavaScript的学习。

试试直接点击“Run”按钮执行下面的JavaScript代码:

1
2
3
4
5
// 以双斜杠开头直到行末的是注释,注释是给人看的,会被浏览器忽略
/* 在这中间的也是注释,将被浏览器忽略 */
// 第一个JavaScript代码:

alert('Hello, world'); // 观察执行效果

浏览器将弹出一个对话框,显示“Hello, world”。你也可以修改两个单引号中间的内容,再试着运行。

调试

俗话说得好,“工欲善其事,必先利其器。”,写JavaScript的时候,如果期望显示ABC,结果却显示XYZ,到底代码哪里出了问题?不要抓狂,也不要泄气,作为小白,要坚信:JavaScript本身没有问题,浏览器执行也没有问题,有问题的一定是我的代码。

如何找出问题代码?这就需要调试。

怎么在浏览器中调试JavaScript代码呢?

首先,你需要安装Google Chrome浏览器,Chrome浏览器对开发者非常友好,可以让你方便地调试JavaScript代码。从这里下载Chrome浏览器。打开网页出问题的童鞋请移步国内镜像

安装后,随便打开一个网页,然后点击菜单“查看(View)”-“开发者(Developer)”-“开发者工具(Developer Tools)”,浏览器窗口就会一分为二,下方就是开发者工具:

chrome dev tools

先点击“控制台(Console)“,在这个面板里可以直接输入JavaScript代码,按回车后执行。

要查看一个变量的内容,在Console中输入console.log(a);,回车后显示的值就是变量的内容。

关闭Console请点击右上角的“×”按钮。请熟练掌握Console的使用方法,在编写JavaScript代码时,经常需要在Console运行测试代码。

如果你对自己还有更高的要求,可以研究开发者工具的“源码(Sources)”,掌握断点、单步执行等高级调试技巧。

练习

打开新浪首页,然后查看页面源代码,找一找引入的JavaScript文件和直接编写在页面中的JavaScript代码。然后在Chrome中打开开发者工具,在控制台输入console.log('Hello');,回车查看JavaScript代码执行结果。

基本语法

JavaScript的语法和Java语言类似,每个语句以;结束,语句块用{...}。但是,JavaScript并不强制要求在每个语句的结尾加;,浏览器中负责执行JavaScript代码的引擎会自动在每个语句的结尾补上;

提示

让JavaScript引擎自动加分号在某些情况下会改变程序的语义,导致运行结果与期望不一致。在本教程中,我们不会省略;,所有语句都会添加;。

例如,下面的一行代码就是一个完整的赋值语句:

1
var x = 1;

下面的一行代码是一个字符串,但仍然可以视为一个完整的语句:

1
'Hello, world';

下面的一行代码包含两个语句,每个语句用;表示语句结束:

1
var x = 1; var y = 2; // 不建议一行写多个语句!

语句块是一组语句的集合,例如,下面的代码先做了一个判断,如果判断成立,将执行{...}中的所有语句:

1
2
3
4
5
if (2 > 1) {
x = 1;
y = 2;
z = 3;
}

注意花括号{...}内的语句具有缩进,通常是4个空格。缩进不是JavaScript语法要求必须的,但缩进有助于我们理解代码的层次,所以编写代码时要遵守缩进规则。很多文本编辑器具有“自动缩进”的功能,可以帮助整理代码。

{...}还可以嵌套,形成层级结构:

1
2
3
4
5
6
7
8
9
10
11
if (2 > 1) {
x = 1;
y = 2;
z = 3;
if (x < y) {
z = 4;
}
if (x > y) {
z = 5;
}
}

JavaScript本身对嵌套的层级没有限制,但是过多的嵌套无疑会大大增加看懂代码的难度。遇到这种情况,需要把部分代码抽出来,作为函数来调用,这样可以减少代码的复杂度。

注释

//开头直到行末的字符被视为行注释,注释是给开发人员看到,JavaScript引擎会自动忽略:

1
2
// 这是一行注释
alert('hello'); // 这也是注释

另一种块注释是用/*...*/把多行字符包裹起来,把一大“块”视为一个注释:

1
2
3
4
/* 从这里开始是块注释
仍然是注释
仍然是注释
注释结束 */

练习

分别利用行注释和块注释把下面的语句注释掉,使它不再执行:

1
2
3
// 请注释掉下面的语句:
alert('我不想执行');
alert('我也不想执行');

大小写

请注意,JavaScript严格区分大小写,如果弄错了大小写,程序将报错或者运行不正常。



数据类型

计算机顾名思义就是可以做数学计算的机器,因此,计算机程序理所当然地可以处理各种数值。但是,计算机能处理的远不止数值,还可以处理文本、图形、音频、视频、网页等各种各样的数据,不同的数据,需要定义不同的数据类型。在JavaScript中定义了以下几种数据类型:

Number

JavaScript不区分整数和浮点数,统一用Number表示,以下都是合法的Number类型:

1
2
3
4
5
6
123; // 整数123
0.456; // 浮点数0.456
1.2345e3; // 科学计数法表示1.2345x1000,等同于1234.5
-99; // 负数
NaN; // NaN表示Not a Number,当无法计算结果时用NaN表示
Infinity; // Infinity表示无限大,当数值超过了JavaScript的Number所能表示的最大值时,就表示为Infinity

计算机由于使用二进制,所以,有时候用十六进制表示整数比较方便,十六进制用0x前缀和0-9,a-f表示,例如:0xff000xa5b4c3d2,等等,它们和十进制表示的数值完全一样。

Number可以直接做四则运算,规则和数学一致:

1
2
3
4
5
6
1 + 2; // 3
(1 + 2) * 5 / 2; // 7.5
2 / 0; // Infinity
0 / 0; // NaN
10 % 3; // 1
10.5 % 3; // 1.5

注意%是求余运算。

要注意,JavaScript的Number不区分整数和浮点数,也就是说,12.00 === 12。(在大多数其他语言中,整数和浮点数不能直接比较)并且,JavaScript的整数最大范围不是±263,而是±253,因此,超过253的整数就可能无法精确表示:

1
2
3
4
5
6
7
// 计算圆面积:
var r = 123.456;
var s = 3.14 * r * r;
console.log(s); // 47857.94555904001

// 打印Number能表示的最大整数:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991

字符串

字符串是以单引号’或双引号"括起来的任意文本,比如'abc'"xyz"等等。请注意,''""本身只是一种表示方式,不是字符串的一部分,因此,字符串'abc'只有abc这3个字符。

布尔值

布尔值和布尔代数的表示完全一致,一个布尔值只有truefalse两种值,要么是true,要么是false,可以直接用truefalse表示布尔值,也可以通过布尔运算计算出来:

1
2
3
4
true; // 这是一个true值
false; // 这是一个false值
2 > 1; // 这是一个true值
2 >= 3; // 这是一个false值

&&运算是与运算,只有所有都为true&&运算结果才是true

1
2
3
true && true; // 这个&&语句计算结果为true
true && false; // 这个&&语句计算结果为false
false && true && false; // 这个&&语句计算结果为false

||运算是或运算,只要其中有一个为true||运算结果就是true

1
2
3
false || false; // 这个||语句计算结果为false
true || false; // 这个||语句计算结果为true
false || true || false; // 这个||语句计算结果为true

!运算是非运算,它是一个单目运算符,把true变成falsefalse变成true

1
2
3
! true; // 结果为false
! false; // 结果为true
! (2 > 5); // 结果为true

布尔值经常用在条件判断中,比如:

1
2
3
4
5
6
var age = 15;
if (age >= 18) {
alert('adult');
} else {
alert('teenager');
}

比较运算符

当我们对Number做比较时,可以通过比较运算符得到一个布尔值:

1
2
3
2 > 5; // false
5 >= 2; // true
7 == 7; // true

实际上,JavaScript允许对任意数据类型做比较:

1
2
false == 0; // true
false === 0; // false

要特别注意相等运算符==。JavaScript在设计时,有两种比较运算符:

第一种是==比较,它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;

第二种是===比较,它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。

由于JavaScript这个设计缺陷,不要使用==比较,始终坚持使用===比较。

另一个例外是NaN这个特殊的Number与所有其他值都不相等,包括它自己:

1
NaN === NaN; // false

唯一能判断NaN的方法是通过isNaN()函数:

1
isNaN(NaN); // true

最后要注意浮点数的相等比较:

1
1 / 3 === (1 - 2 / 3); // false

这不是JavaScript的设计缺陷。浮点数在运算过程中会产生误差,因为计算机无法精确表示无限循环小数。要比较两个浮点数是否相等,只能计算它们之差的绝对值,看是否小于某个阈值:

1
Math.abs(1 / 3 - (1 - 2 / 3)) < 0.0000001; // true

BigInt

要精确表示比253还大的整数,可以使用内置的BigInt类型,它的表示方法是在整数后加一个n,例如9223372036854775808n,也可以使用BigInt()把Number和字符串转换成BigInt:

1
2
3
4
5
6
7
// 使用BigInt:
var bi1 = 9223372036854775807n;
var bi2 = BigInt(12345);
var bi3 = BigInt("0x7fffffffffffffff");
console.log(bi1 === bi2); // false
console.log(bi1 === bi3); // true
console.log(bi1 + bi2);

使用BigInt可以正常进行加减乘除等运算,结果仍然是一个BigInt,但不能把一个BigInt和一个Number放在一起运算:

1
2
3
4
5
// 使用BigInt:
console.log(1234567n + 3456789n); // OK
console.log(1234567n / 789n); // 1564, 除法运算结果仍然是BigInt
console.log(1234567n % 789n); // 571, 求余
console.log(1234567n + 3456789); // Uncaught TypeError: Cannot mix BigInt and other types

null和undefined

null表示一个“空”的值,它和0以及空字符串''不同,0是一个数值,''表示长度为0的字符串,而null表示“空”。

在其他语言中,也有类似JavaScript的null的表示,例如Java也用null,Swift用nil,Python用None表示。但是,在JavaScript中,还有一个和null类似的undefined,它表示“未定义”。

JavaScript的设计者希望用null表示一个空的值,而undefined表示值未定义。事实证明,这并没有什么卵用,区分两者的意义不大。大多数情况下,我们都应该用nullundefined仅仅在判断函数参数是否传递的情况下有用。

数组

数组是一组按顺序排列的集合,集合的每个值称为元素。JavaScript的数组可以包括任意数据类型。例如:

1
[1, 2, 3.14, 'Hello', null, true];

上述数组包含6个元素。数组用[]表示,元素之间用,分隔。

另一种创建数组的方法是通过Array()函数实现:

1
new Array(1, 2, 3); // 创建了数组[1, 2, 3]

然而,出于代码的可读性考虑,强烈建议直接使用[]

数组的元素可以通过索引来访问。请注意,索引的起始值为0

1
2
3
4
5
var arr = [1, 2, 3.14, 'Hello', null, true];
arr[0]; // 返回索引为0的元素,即1
arr[5]; // 返回索引为5的元素,即true
arr[6]; // 索引超出了范围,返回undefined
console.log(arr[0], arr[5], arr[6]);

对象

JavaScript的对象是一组由键-值组成的无序集合,例如:

1
2
3
4
5
6
7
8
var person = {
name: 'Bob',
age: 20,
tags: ['js', 'web', 'mobile'],
city: 'Beijing',
hasCar: true,
zipcode: null
};

JavaScript对象的键都是字符串类型,值可以是任意数据类型。上述person对象一共定义了6个键值对,其中每个键又称为对象的属性,例如,personname属性为'Bob'zipcode属性为null

要获取一个对象的属性,我们用对象变量.属性名的方式:

1
2
person.name; // 'Bob'
person.zipcode; // null

变量

变量的概念基本上和初中代数的方程变量是一致的,只是在计算机程序中,变量不仅可以是数字,还可以是任意数据类型。

变量在JavaScript中就是用一个变量名表示,变量名是大小写英文、数字、$_的组合,且不能用数字开头。变量名也不能是JavaScript的关键字,如ifwhile等。申明一个变量用var语句,比如:

1
2
3
4
5
var a; // 申明了变量a,此时a的值为undefined
var $b = 1; // 申明了变量$b,同时给$b赋值,此时$b的值为1
var s_007 = '007'; // s_007是一个字符串
var Answer = true; // Answer是一个布尔值true
var t = null; // t的值是null

变量名也可以用中文,但是,请不要给自己找麻烦。

在JavaScript中,使用等号=对变量进行赋值。可以把任意数据类型赋值给变量,同一个变量可以反复赋值,而且可以是不同类型的变量,但是要注意只能用var申明一次,例如:

1
2
var a = 123; // a的值是整数123
a = 'ABC'; // a变为字符串

这种变量本身类型不固定的语言称之为动态语言,与之对应的是静态语言。静态语言在定义变量时必须指定变量类型,如果赋值的时候类型不匹配,就会报错。例如Java是静态语言,赋值语句如下:

1
2
int a = 123; // a是整数类型变量,类型用int申明
a = "ABC"; // 错误:不能把字符串赋给整型变量

和静态语言相比,动态语言更灵活,就是这个原因。

请不要把赋值语句的等号等同于数学的等号。比如下面的代码:

1
2
var x = 10;
x = x + 2;

如果从数学上理解x = x + 2那无论如何是不成立的,在程序中,赋值语句先计算右侧的表达式x + 2,得到结果12,再赋给变量x。由于x之前的值是10,重新赋值后,x的值变成12

要显示变量的内容,可以用console.log(x),打开Chrome的控制台就可以看到结果。

1
2
3
// 打印变量x
var x = 100;
console.log(x);

使用console.log()代替alert()的好处是可以避免弹出烦人的对话框。

strict模式

JavaScript在设计之初,为了方便初学者学习,并不强制要求用var申明变量。这个设计错误带来了严重的后果:如果一个变量没有通过var申明就被使用,那么该变量就自动被申明为全局变量:

1
i = 10; // i现在是全局变量

在同一个页面的不同的JavaScript文件中,如果都不用var申明,恰好都使用了变量i,将造成变量i互相影响,产生难以调试的错误结果。

使用var申明的变量则不是全局变量,它的范围被限制在该变量被申明的函数体内(函数的概念将稍后讲解),同名变量在不同的函数体内互不冲突。

为了修补JavaScript这一严重设计缺陷,ECMA在后续规范中推出了strict模式,在strict模式下运行的JavaScript代码,强制通过var申明变量,未使用var申明变量就使用的,将导致运行错误。

启用strict模式的方法是在JavaScript代码的第一行写上:

1
'use strict';

这是一个字符串,不支持strict模式的浏览器会把它当做一个字符串语句执行,支持strict模式的浏览器将开启strict模式运行JavaScript。

来测试一下你的浏览器是否能支持strict模式:

1
2
3
4
5
6
7
8
function hello() {
'use strict';
// 如果浏览器支持strict模式,
// 下面的代码将报ReferenceError错误:
helloStr = 'hello';
console.log(helloStr);
}
hello();

运行代码,如果浏览器报错,请修复后再运行。如果浏览器不报错,说明你的浏览器太古老了,需要尽快升级。

不用var申明的变量会被视为全局变量,为了避免这一缺陷,所有的JavaScript代码都应该使用strict模式。我们在后面编写的JavaScript代码将全部采用strict模式。

另一种申明变量的方式是let,这也是现代JavaScript推荐的方式:

1
2
3
// 用let申明变量:
let s = 'hello';
console.log(s);

后续我们还会详细讨论varlet的区别。

JavaScript的字符串就是用''""括起来的字符表示。

如果'本身也是一个字符,那就可以用""括起来,比如"I'm OK"包含的字符是I'm,空格,OK这6个字符。

如果字符串内部既包含'又包含"怎么办?可以用转义字符\来标识,比如:

1
'I\'m \"OK\"!'; // I'm "OK"!

表示的字符串内容是:I'm "OK"!

转义字符\可以转义很多字符,比如\n表示换行,\t表示制表符,字符\本身也要转义,所以\\表示的字符就是\

ASCII字符可以以\x##形式的十六进制表示,例如:

1
'\x41'; // 完全等同于 'A'

还可以用\u####表示一个Unicode字符:

1
'\u4e2d\u6587'; // 完全等同于 '中文'

多行字符串

由于多行字符串用\n写起来比较费事,所以最新的ES6标准新增了一种多行字符串的表示方法,用反引号`…`表示:

1
2
3
`这是一个
多行
字符串`;

注意:反引号在键盘的ESC下方,数字键1的左边:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────┐ ┌─────┬─────┬─────┬─────┐
│ ESC │ │ F1 │ F2 │ F3 │ F4 │
└─────┘ └─────┴─────┴─────┴─────┘
┌─────┬─────┬─────┬─────┬─────┐
│ ~ │ ! │ @ │ # │ $ │
│ ` │ 1 │ 2 │ 3 │ 4 │
├─────┴──┬──┴──┬──┴──┬──┴──┬──┘
│ │ │ │ │
│ tab │ Q │ W │ E │
├────────┴──┬──┴──┬──┴──┬──┘
│ │ │ │
│ caps lock │ A │ S │
└───────────┴─────┴─────┘

练习:测试你的浏览器是否支持ES6标准,如果不支持,请把多行字符串用\n重新表示出来:

1
2
3
4
// 如果浏览器不支持ES6,将报SyntaxError错误:
console.log(`多行
字符串
测试`);

模板字符串

要把多个字符串连接起来,可以用+号连接:

1
2
3
4
let name = '小明';
let age = 20;
let message = '你好, ' + name + ', 你今年' + age + '岁了!';
alert(message);

如果有很多变量需要连接,用+号就比较麻烦。ES6新增了一种模板字符串,表示方法和上面的多行字符串一样,但是它会自动替换字符串中的变量:

1
2
3
4
let name = '小明';
let age = 20;
let message = `你好, ${name}, 你今年${age}岁了!`;
alert(message);

练习:测试你的浏览器是否支持ES6模板字符串,如果不支持,请把模板字符串改为+连接的普通字符串:

1
2
3
4
// 如果浏览器支持模板字符串,将会替换字符串内部的变量:
let name = '小明';
let age = 20;
console.log(`你好, ${name}, 你今年${age}岁了!`);

操作字符串

字符串常见的操作如下:

获取字符串长度:

1
2
let s = 'Hello, world!';
s.length; // 13

要获取字符串某个指定位置的字符,使用类似Array的下标操作,索引号从0开始:

1
2
3
4
5
6
7
let s = 'Hello, world!';

s[0]; // 'H'
s[6]; // ' '
s[7]; // 'w'
s[12]; // '!'
s[13]; // undefined 超出范围的索引不会报错,但一律返回undefined

需要特别注意的是,字符串是不可变的,如果对字符串的某个索引赋值,不会有任何错误,但是,也没有任何效果:

1
2
3
let s = 'Test';
s[0] = 'X';
console.log(s); // s仍然为'Test'

JavaScript为字符串提供了一些常用方法,注意,调用这些方法本身不会改变原有字符串的内容,而是返回一个新字符串:

toUpperCase

toUpperCase()把一个字符串全部变为大写:

1
2
let s = 'Hello';
s.toUpperCase(); // 返回'HELLO'

toLowerCase

toLowerCase()把一个字符串全部变为小写:

1
2
3
let s = 'Hello';
let lower = s.toLowerCase(); // 返回'hello'并赋值给变量lower
lower; // 'hello'

indexOf

indexOf()会搜索指定字符串出现的位置:

1
2
3
let s = 'hello, world';
s.indexOf('world'); // 返回7
s.indexOf('World'); // 没有找到指定的子串,返回-1

substring

substring()返回指定索引区间的子串:

1
2
3
let s = 'hello, world'
s.substring(0, 5); // 从索引0开始到5(不包括5),返回'hello'
s.substring(7); // 从索引7开始到结束,返回'world'

JavaScript的Array可以包含任意数据类型,并通过索引来访问每个元素。

要取得Array的长度,直接访问length属性:

1
2
3
// Array.length:
let arr = [1, 2, 3.14, 'Hello', null, true];
console.log(arr.length); // 6

请注意,直接给Arraylength赋一个新的值会导致Array大小的变化:

1
2
3
4
5
6
7
8
let arr = ['A', 'B', 'C'];
console.log(arr.length); // 3
// 调整数组大小:
arr.length = 6;
console.log(arr); // arr变为['A', 'B', 'C', undefined, undefined, undefined]
// 调整数组大小:
arr.length = 2;
console.log(arr); // arr变为['A', 'B']

Array可以通过索引把对应的元素修改为新的值,因此,对Array的索引进行赋值会直接修改这个Array

1
2
3
4
5
// Array index:
let arr = ['A', 'B', 'C'];
arr[1] = 99;
console.log(arr); // arr现在变为['A', 99, 'C']
console.log(arr[1]); // 99

请注意,如果通过索引赋值时,索引超过了范围,同样会引起Array大小的变化:

1
2
3
4
// 索引超出范围会导致数组大小自动调整:
let arr = ['A', 'B', 'C'];
arr[5] = 'x';
console.log(arr); // arr变为['A', 'B', 'C', undefined, undefined, 'x']

大多数其他编程语言不允许直接改变数组的大小,越界访问索引会报错。然而,JavaScript的Array却不会有任何错误。在编写代码时,不建议直接修改Array的大小,访问索引时要确保索引不会越界。

indexOf

与String类似,Array也可以通过indexOf()来搜索一个指定的元素的位置:

1
2
3
4
5
let arr = [10, 20, '30', 'xyz'];
arr.indexOf(10); // 元素10的索引为0
arr.indexOf(20); // 元素20的索引为1
arr.indexOf(30); // 元素30没有找到,返回-1
arr.indexOf('30'); // 元素'30'的索引为2

注意了,数字30和字符串'30'是不同的元素。

slice

slice()就是对应String的substring()版本,它截取Array的部分元素,然后返回一个新的Array

1
2
3
let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
arr.slice(0, 3); // 从索引0开始,到索引3结束,但不包括索引3: ['A', 'B', 'C']
arr.slice(3); // 从索引3开始到结束: ['D', 'E', 'F', 'G']

注意到slice()的起止参数包括开始索引,不包括结束索引。

如果不给slice()传递任何参数,它就会从头到尾截取所有元素。利用这一点,我们可以很容易地复制一个Array

1
2
3
4
let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
let aCopy = arr.slice();
aCopy; // ['A', 'B', 'C', 'D', 'E', 'F', 'G']
aCopy === arr; // false

push和pop

push()Array的末尾添加若干元素,pop()则把Array的最后一个元素删除掉:

1
2
3
4
5
6
7
8
9
let arr = [1, 2];
arr.push('A', 'B'); // 返回Array新的长度: 4
arr; // [1, 2, 'A', 'B']
arr.pop(); // pop()返回'B'
arr; // [1, 2, 'A']
arr.pop(); arr.pop(); arr.pop(); // 连续pop 3次
arr; // []
arr.pop(); // 空数组继续pop不会报错,而是返回undefined
arr; // []

unshift和shift

如果要往Array的头部添加若干元素,使用unshift()方法,shift()方法则把Array的第一个元素删掉:

1
2
3
4
5
6
7
8
9
let arr = [1, 2];
arr.unshift('A', 'B'); // 返回Array新的长度: 4
arr; // ['A', 'B', 1, 2]
arr.shift(); // 'A'
arr; // ['B', 1, 2]
arr.shift(); arr.shift(); arr.shift(); // 连续shift 3次
arr; // []
arr.shift(); // 空数组继续shift不会报错,而是返回undefined
arr; // []

sort

sort()可以对当前Array进行排序,它会直接修改当前Array的元素位置,直接调用时,按照默认顺序排序:

1
2
3
let arr = ['B', 'C', 'A'];
arr.sort();
arr; // ['A', 'B', 'C']

能否按照我们自己指定的顺序排序呢?完全可以,我们将在后面的函数中讲到。

reverse

reverse()把整个Array的元素给调个个,也就是反转:

1
2
3
let arr = ['one', 'two', 'three'];
arr.reverse();
arr; // ['three', 'two', 'one']

splice

splice()方法是修改Array的“万能方法”,它可以从指定的索引开始删除若干元素,然后再从该位置添加若干元素:

1
2
3
4
5
6
7
8
9
10
let arr = ['Microsoft', 'Apple', 'Yahoo', 'AOL', 'Excite', 'Oracle'];
// 从索引2开始删除3个元素,然后再添加两个元素:
arr.splice(2, 3, 'Google', 'Facebook'); // 返回删除的元素 ['Yahoo', 'AOL', 'Excite']
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']
// 只删除,不添加:
arr.splice(2, 2); // ['Google', 'Facebook']
arr; // ['Microsoft', 'Apple', 'Oracle']
// 只添加,不删除:
arr.splice(2, 0, 'Google', 'Facebook'); // 返回[],因为没有删除任何元素
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']

concat

concat()方法把当前的Array和另一个Array连接起来,并返回一个新的Array

1
2
3
4
let arr = ['A', 'B', 'C'];
let added = arr.concat([1, 2, 3]);
added; // ['A', 'B', 'C', 1, 2, 3]
arr; // ['A', 'B', 'C']

请注意concat()方法并没有修改当前Array,而是返回了一个新的Array

实际上,concat()方法可以接收任意个元素和Array,并且自动把Array拆开,然后全部添加到新的Array里:

1
2
let arr = ['A', 'B', 'C'];
arr.concat(1, 2, [3, 4]); // ['A', 'B', 'C', 1, 2, 3, 4]

join

join()方法是一个非常实用的方法,它把当前Array的每个元素都用指定的字符串连接起来,然后返回连接后的字符串:

1
2
let arr = ['A', 'B', 'C', 1, 2, 3];
arr.join('-'); // 'A-B-C-1-2-3'

练习:在新生欢迎会上,你已经拿到了新同学的名单,请排序后显示:欢迎XXX,XXX,XXX和XXX同学!:

1
2
3
let arr = ['小明', '小红', '大军', '阿黄'];

console.log('???');

如果Array的元素不是字符串,将自动转换为字符串后再连接。

多维数组

如果数组的某个元素又是一个Array,则可以形成多维数组,例如:

1
let arr = [[1, 2, 3], [400, 500, 600], '-'];

上述Array包含3个元素,其中头两个元素本身也是Array

练习:如何通过索引取到500这个值:

1
2
3
4
let arr = [[1, 2, 3], [400, 500, 600], '-'];

let x = ??;
console.log(x); // x应该为500

小结

Array提供了一种顺序存储一组元素的功能,并可以按索引来读写。

JavaScript的对象是一种无序的集合数据类型,它由若干键值对组成。

JavaScript的对象用于描述现实世界中的某个对象。例如,为了描述“小明”这个淘气的小朋友,我们可以用若干键值对来描述他:

1
2
3
4
5
6
7
8
let xiaoming = {
name: '小明',
birth: 1990,
school: 'No.1 Middle School',
height: 1.70,
weight: 65,
score: null
};

JavaScript用一个{...}表示一个对象,键值对以xxx: xxx形式申明,用,隔开。注意,最后一个键值对不需要在末尾加,,如果加了,有的浏览器(如低版本的IE)将报错。

上述对象申明了一个name属性,值是'小明'birth属性,值是1990,以及其他一些属性。最后,把这个对象赋值给变量xiaoming后,就可以通过变量xiaoming来获取小明的属性了:

1
2
xiaoming.name; // '小明'
xiaoming.birth; // 1990

访问属性是通过.操作符完成的,但这要求属性名必须是一个有效的变量名。如果属性名包含特殊字符,就必须用''括起来:

1
2
3
4
let xiaohong = {
name: '小红',
'middle-school': 'No.1 Middle School'
};

xiaohong的属性名middle-school不是一个有效的变量,就需要用''括起来。访问这个属性也无法使用.操作符,必须用['xxx']来访问:

1
2
3
xiaohong['middle-school']; // 'No.1 Middle School'
xiaohong['name']; // '小红'
xiaohong.name; // '小红'

也可以用xiaohong['name']来访问xiaohongname属性,不过xiaohong.name的写法更简洁。我们在编写JavaScript代码的时候,属性名尽量使用标准的变量名,这样就可以直接通过object.prop的形式访问一个属性了。

实际上JavaScript对象的所有属性都是字符串,不过属性对应的值可以是任意数据类型。

如果访问一个不存在的属性会返回什么呢?JavaScript规定,访问不存在的属性不报错,而是返回undefined

1
2
3
4
5
6
7
8
'use strict';

let xiaoming = {
name: '小明'
};

console.log(xiaoming.name);
console.log(xiaoming.age); // undefined

由于JavaScript的对象是动态类型,你可以自由地给一个对象添加或删除属性:

1
2
3
4
5
6
7
8
9
10
11
let xiaoming = {
name: '小明'
};
xiaoming.age; // undefined
xiaoming.age = 18; // 新增一个age属性
xiaoming.age; // 18
delete xiaoming.age; // 删除age属性
xiaoming.age; // undefined
delete xiaoming['name']; // 删除name属性
xiaoming.name; // undefined
delete xiaoming.school; // 删除一个不存在的school属性也不会报错

如果我们要检测xiaoming是否拥有某一属性,可以用in操作符:

1
2
3
4
5
6
7
8
9
10
let xiaoming = {
name: '小明',
birth: 1990,
school: 'No.1 Middle School',
height: 1.70,
weight: 65,
score: null
};
'name' in xiaoming; // true
'grade' in xiaoming; // false

不过要小心,如果in判断一个属性存在,这个属性不一定是xiaoming的,它可能是xiaoming继承得到的:

1
'toString' in xiaoming; // true

因为toString定义在object对象中,而所有对象最终都会在原型链上指向object,所以xiaoming也拥有toString属性。

要判断一个属性是否是xiaoming自身拥有的,而不是继承得到的,可以用hasOwnProperty()方法:

1
2
3
4
5
let xiaoming = {
name: '小明'
};
xiaoming.hasOwnProperty('name'); // true
xiaoming.hasOwnProperty('toString'); // false

条件判断

JavaScript使用if () { ... } else { ... }来进行条件判断。例如,根据年龄显示不同内容,可以用if语句实现如下:

1
2
3
4
5
6
let age = 20;
if (age >= 18) { // 如果age >= 18为true,则执行if语句块
console.log('adult');
} else { // 否则执行else语句块
console.log('teenager');
}

其中else语句是可选的。如果语句块只包含一条语句,那么可以省略{}

1
2
3
4
5
let age = 20;
if (age >= 18)
console.log('adult');
else
console.log('teenager');

省略{}的危险之处在于,如果后来想添加一些语句,却忘了写{},就改变了if...else...的语义,例如:

1
2
3
4
5
6
let age = 20;
if (age >= 18)
console.log('adult');
else
console.log('age < 18'); // 添加一行日志
console.log('teenager'); // <- 这行语句已经不在else的控制范围了

上述代码的else子句实际上只负责执行console.log('age < 18');,原有的console.log('teenager');已经不属于if...else...的控制范围了,它每次都会执行。

相反地,有{}的语句就不会出错:

1
2
3
4
5
6
7
let age = 20;
if (age >= 18) {
console.log('adult');
} else {
console.log('age < 18');
console.log('teenager');
}

这就是为什么我们建议永远都要写上{}

多行条件判断

如果还要更细致地判断条件,可以使用多个if...else...的组合:

1
2
3
4
5
6
7
8
let age = 3;
if (age >= 18) {
console.log('adult');
} else if (age >= 6) {
console.log('teenager');
} else {
console.log('kid');
}

上述多个if...else...的组合实际上相当于两层if...else...

1
2
3
4
5
6
7
8
9
10
let age = 3;
if (age >= 18) {
console.log('adult');
} else {
if (age >= 6) {
console.log('teenager');
} else {
console.log('kid');
}
}

但是我们通常把else if连写在一起,来增加可读性。这里的else略掉了{}是没有问题的,因为它只包含一个if语句。注意最后一个单独的else不要略掉{}

请注意if...else...语句的执行特点是二选一,在多个if...else...语句中,如果某个条件成立,则后续就不再继续判断了。

试解释为什么下面的代码显示的是teenager

1
2
3
4
5
6
7
8
9
10
'use strict';
let age = 20;

if (age >= 6) {
console.log('teenager');
} else if (age >= 18) {
console.log('adult');
} else {
console.log('kid');
}

由于age的值为20,它实际上同时满足条件age >= 6age >= 18,这说明条件判断的顺序非常重要。请修复后让其显示adult

如果if的条件判断语句结果不是truefalse怎么办?例如:

1
2
3
4
let s = '123';
if (s.length) { // 条件计算结果为3
//
}

JavaScript把nullundefined0NaN和空字符串''视为false,其他值一概视为true,因此上述代码条件判断的结果是true

练习

小明身高1.75,体重80.5kg。请根据BMI公式(体重除以身高的平方)帮小明计算他的BMI指数,并根据BMI指数:

  • 低于18.5:过轻
  • 18.5-25:正常
  • 25-28:过重
  • 28-32:肥胖
  • 高于32:严重肥胖

if...else...判断并显示结果:

1
2
3
4
5
6
let height = parseFloat(prompt('请输入身高(m):'));
let weight = parseFloat(prompt('请输入体重(kg):'));

// TODO:
let bmi = ???;
if ...


要计算1+2+3,我们可以直接写表达式:

1
1 + 2 + 3; // 6

要计算1+2+3+…+10,勉强也能写出来。

但是,要计算1+2+3+…+10000,直接写表达式就不可能了。

为了让计算机能计算成千上万次的重复运算,我们就需要循环语句。

JavaScript的循环有两种,一种是for循环,通过初始条件、结束条件和递增条件来循环执行语句块:

1
2
3
4
5
6
let x = 0;
let i;
for (i=1; i<=10000; i++) {
x = x + i;
}
x; // 50005000

让我们来分析一下for循环的控制条件:

  • i=1 这是初始条件,将变量i置为1;
  • i<=10000 这是判断条件,满足时就继续循环,不满足就退出循环;
  • i++ 这是每次循环后的递增条件,由于每次循环后变量i都会加1,因此它终将在若干次循环后不满足判断条件i<=10000而退出循环。

练习:利用for循环计算1 * 2 * 3 * ... * 10的结果:

1
2
3
4
5
6
7
8
9
10
let x = ?;
let i;
for ...

if (x === 3628800) {
console.log('1 x 2 x 3 x ... x 10 = ' + x);
}
else {
console.log('计算错误');
}

for循环最常用的地方是利用索引来遍历数组:

1
2
3
4
5
6
let arr = ['Apple', 'Google', 'Microsoft'];
let i, x;
for (i=0; i<arr.length; i++) {
x = arr[i];
console.log(x);
}

for循环的3个条件都是可以省略的,如果没有退出循环的判断条件,就必须使用break语句退出循环,否则就是死循环:

1
2
3
4
5
6
7
let x = 0;
for (;;) { // 将无限循环下去
if (x > 100) {
break; // 通过if判断来退出循环
}
x ++;
}

for … in

for循环的一个变体是for ... in循环,它可以把一个对象的所有属性依次循环出来:

1
2
3
4
5
6
7
8
let o = {
name: 'Jack',
age: 20,
city: 'Beijing'
};
for (let key in o) {
console.log(key); // 'name', 'age', 'city'
}

要过滤掉对象继承的属性,用hasOwnProperty()来实现:

1
2
3
4
5
6
7
8
9
10
let o = {
name: 'Jack',
age: 20,
city: 'Beijing'
};
for (let key in o) {
if (o.hasOwnProperty(key)) {
console.log(key); // 'name', 'age', 'city'
}
}

由于Array也是对象,而它的每个元素的索引被视为对象的属性,因此,for ... in循环可以直接循环出Array的索引:

1
2
3
4
5
let a = ['A', 'B', 'C'];
for (let i in a) {
console.log(i); // '0', '1', '2'
console.log(a[i]); // 'A', 'B', 'C'
}

请注意for ... inArray的循环得到的是String而不是Number

while

for循环在已知循环的初始和结束条件时非常有用。而上述忽略了条件的for循环容易让人看不清循环的逻辑,此时用while循环更佳。

while循环只有一个判断条件,条件满足,就不断循环,条件不满足时则退出循环。比如我们要计算100以内所有奇数之和,可以用while循环实现:

1
2
3
4
5
6
7
let x = 0;
let n = 99;
while (n > 0) {
x = x + n;
n = n - 2;
}
x; // 2500

在循环内部变量n不断自减,直到变为-1时,不再满足while条件,循环退出。

do … while

最后一种循环是do { ... } while()循环,它和while循环的唯一区别在于,不是在每次循环开始的时候判断条件,而是在每次循环完成的时候判断条件:

1
2
3
4
5
let n = 0;
do {
n = n + 1;
} while (n < 100);
n; // 100

do { ... } while()循环要小心,循环体会至少执行1次,而forwhile循环则可能一次都不执行。

练习

请利用循环遍历数组中的每个名字,并显示Hello, xxx!

1
2
3
let arr = ['Bart', 'Lisa', 'Adam'];

for ...

请尝试for循环和while循环,并以正序、倒序两种方式遍历。

小结

循环是让计算机做重复任务的有效的方法,有些时候,如果代码写得有问题,会让程序陷入“死循环”,也就是永远循环下去。JavaScript的死循环会让浏览器无法正常显示或执行当前页面的逻辑,有的浏览器会直接挂掉,有的浏览器会在一段时间后提示你强行终止JavaScript的执行,因此,要特别注意死循环的问题。

在编写循环代码时,务必小心编写初始条件和判断条件,尤其是边界值。特别注意i < 100i <= 100是不同的判断逻辑。

JavaScript的默认对象表示方式{}可以视为其他语言中的MapDictionary的数据结构,即一组键值对。

但是JavaScript的对象有个小问题,就是键必须是字符串。但实际上Number或者其他数据类型作为键也是非常合理的。

为了解决这个问题,最新的ES6规范引入了新的数据类型Map。要测试你的浏览器是否支持ES6规范,请执行以下代码,如果浏览器报ReferenceError错误,那么你需要换一个支持ES6的浏览器:

1
2
3
4
let m = new Map();
let s = new Set();
console.log('你的浏览器支持Map和Set!');
// 直接运行测试

Map

Map是一组键值对的结构,具有极快的查找速度。

举个例子,假设要根据同学的名字查找对应的成绩,如果用Array实现,需要两个Array

1
2
let names = ['Michael', 'Bob', 'Tracy'];
let scores = [95, 75, 85];

给定一个名字,要查找对应的成绩,就先要在names中找到对应的位置,再从scores取出对应的成绩,Array越长,耗时越长。

如果用Map实现,只需要一个“名字”-“成绩”的对照表,直接根据名字查找成绩,无论这个表有多大,查找速度都不会变慢。用JavaScript写一个Map如下:

1
2
let m = new Map([['Michael', 95], ['Bob', 75], ['Tracy', 85]]);
m.get('Michael'); // 95

初始化Map需要一个二维数组,或者直接初始化一个空MapMap具有以下方法:

1
2
3
4
5
6
7
let m = new Map(); // 空Map
m.set('Adam', 67); // 添加新的key-value
m.set('Bob', 59);
m.has('Adam'); // 是否存在key 'Adam': true
m.get('Adam'); // 67
m.delete('Adam'); // 删除key 'Adam'
m.get('Adam'); // undefined

由于一个key只能对应一个value,所以,多次对一个key放入value,后面的值会把前面的值冲掉:

1
2
3
4
let m = new Map();
m.set('Adam', 67);
m.set('Adam', 88);
m.get('Adam'); // 88

Set

SetMap类似,也是一组key的集合,但不存储value。由于key不能重复,所以,在Set中,没有重复的key。

要创建一个Set,需要提供一个Array作为输入,或者直接创建一个空Set

1
2
let s1 = new Set(); // 空Set
let s2 = new Set([1, 2, 3]); // 含1, 2, 3

重复元素在Set中自动被过滤:

1
2
let s = new Set([1, 2, 3, 3, '3']);
s; // Set {1, 2, 3, "3"}

注意数字3和字符串'3'是不同的元素。

通过add(key)方法可以添加元素到Set中,可以重复添加,但不会有效果:

1
2
3
4
s.add(4);
s; // Set {1, 2, 3, 4}
s.add(4);
s; // 仍然是 Set {1, 2, 3, 4}

通过delete(key)方法可以删除元素:

1
2
3
4
let s = new Set([1, 2, 3]);
s; // Set {1, 2, 3}
s.delete(3);
s; // Set {1, 2}

小结

MapSet是ES6标准新增的数据类型,请根据浏览器的支持情况决定是否要使用。

遍历Array可以采用下标循环,遍历MapSet就无法使用下标。为了统一集合类型,ES6标准引入了新的iterable类型,ArrayMapSet都属于iterable类型。

具有iterable类型的集合可以通过新的for ... of循环来遍历。

for ... of循环是ES6引入的新的语法,请测试你的浏览器是否支持:

1
2
3
4
5
let a = [1, 2, 3];
for (let x of a) {
}
console.log('你的浏览器支持for ... of');
// 请直接运行测试

for ... of循环遍历集合,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
let a = ['A', 'B', 'C'];
let s = new Set(['A', 'B', 'C']);
let m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
for (let x of a) { // 遍历Array
console.log(x);
}
for (let x of s) { // 遍历Set
console.log(x);
}
for (let x of m) { // 遍历Map
console.log(x[0] + '=' + x[1]);
}

你可能会有疑问,for ... of循环和for ... in循环有何区别?

for ... in循环由于历史遗留问题,它遍历的实际上是对象的属性名称。一个Array数组实际上也是一个对象,它的每个元素的索引被视为一个属性。

当我们手动给Array对象添加了额外的属性后,for ... in循环将带来意想不到的意外效果:

1
2
3
4
5
let a = ['A', 'B', 'C'];
a.name = 'Hello';
for (let x in a) {
console.log(x); // '0', '1', '2', 'name'
}

for ... in循环将把name包括在内,但Arraylength属性却不包括在内。

for ... of循环则完全修复了这些问题,它只循环集合本身的元素:

1
2
3
4
5
let a = ['A', 'B', 'C'];
a.name = 'Hello';
for (let x of a) {
console.log(x); // 'A', 'B', 'C'
}

这就是为什么要引入新的for ... of循环。

然而,更好的方式是直接使用iterable内置的forEach方法,它接收一个函数,每次迭代就自动回调该函数。以Array为例:

1
2
3
4
5
6
7
let a = ['A', 'B', 'C'];
a.forEach(function (element, index, array) {
// element: 指向当前元素的值
// index: 指向当前索引
// array: 指向Array对象本身
console.log(`${element}, index = ${index}`);
});

注意forEach()方法是ES5.1标准引入的,你需要测试浏览器是否支持。

SetArray类似,但Set没有索引,因此回调函数的前两个参数都是元素本身:

1
2
3
4
let s = new Set(['A', 'B', 'C']);
s.forEach(function (element, sameElement, set) {
console.log(element);
});

Map的回调函数参数依次为valuekeymap本身:

1
2
3
4
let m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
m.forEach(function (value, key, map) {
console.log(value);
});

如果对某些参数不感兴趣,由于JavaScript的函数调用不要求参数必须一致,因此可以忽略它们。例如,只需要获得Arrayelement

1
2
3
4
let a = ['A', 'B', 'C'];
a.forEach(function (element) {
console.log(element);
});

留言與分享

SwiftUI 是 Apple 推出的声明式框架,用于构建用户界面(UI)。 自2019年首次发布以来,SwiftUI 已成为 iOS、macOS、watchOS 和 tvOS 应用开发的重要工具。 本文将深入介绍 SwiftUI 的基本概念,包括视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图、 组件化开发以及 MVVM 架构在 SwiftUI 中的应用。无论你是刚开始学习 SwiftUI,还是希望巩固基础,这篇指南都将为你提供有价值的知识。

什么是 SwiftUI

SwiftUI 是 Apple 推出的用于构建用户界面的框架,采用声明式语法,使开发者能够更简洁、高效地创建复杂的界面。与传统的 UIKit 不同,SwiftUI 允许开发者通过声明视图的状态和布局来自动管理 UI 更新,从而减少了大量的样板代码。

SwiftUI 的优势

  • 声明式语法:通过描述界面的状态,SwiftUI 自动处理界面的更新。
  • 跨平台支持:适用于 iOS、macOS、watchOS 和 tvOS。
  • 实时预览:Xcode 提供的 Canvas 允许开发者实时预览和调试 UI。
  • 高效开发:减少样板代码,提高开发效率。
  • 响应式编程:SwiftUI 内置支持响应式编程范式,简化数据与UI的绑定。

什么是视图(View)

在 SwiftUI 中,视图(View) 是构建用户界面的基本单元。每一个视图都代表 UI 的一个部分,例如文本、按钮、图像等。视图可以嵌套和组合,以创建复杂的界面。

常见的 SwiftUI 视图

  • Text:显示文本内容。
  • Image:显示图片。
  • Button:交互式按钮。
  • VStackHStack:垂直和水平堆叠视图。
  • List:显示可滚动的列表。
  • ScrollView:实现可滚动的内容视图。
  • Spacer:在堆叠视图中添加弹性空间。
  • NavigationView:实现导航功能。

示例:创建一个简单的文本视图

1
import SwiftUIstruct ContentView: View {    var body: some View {        Text("Hello, SwiftUI!")    }}

在上述示例中,Text 视图用于显示一段文本。

什么是修饰符(Modifiers)

修饰符(Modifiers) 是 SwiftUI 中用于修改视图属性的方法。通过链式调用修饰符,开发者可以轻松地调整视图的外观和行为,例如颜色、字体、边距等。

使用修饰符的示例

1
Text("Hello, SwiftUI!")    .font(.title)    .foregroundColor(.blue)    .padding()

在这个例子中,Text 视图被应用了三个修饰符:font 修改字体大小,foregroundColor 修改文本颜色,padding 添加内边距。

什么是内置修饰符

SwiftUI 提供了丰富的内置修饰符,开发者可以直接使用这些修饰符来调整视图的各种属性。以下是一些常用的内置修饰符:

  • font(_:):设置字体和字体大小。
  • foregroundColor(_:):设置前景色(如文本颜色)。
  • background(_:):设置视图的背景颜色或视图。
  • padding(_:):设置视图的内边距。
  • frame(width:height:):设置视图的宽度和高度。
  • cornerRadius(_:):设置视图的圆角半径。
  • shadow(color:radius:x:y:):添加阴影效果。
  • opacity(_:):设置视图的不透明度。
  • rotationEffect(_:):旋转视图。
  • scaleEffect(_:):缩放视图。

示例:使用多个内置修饰符

1
Text("Welcome to SwiftUI")    .font(.largeTitle)    .foregroundColor(.white)    .padding()    .background(Color.blue)    .cornerRadius(10)    .shadow(color: .gray, radius: 5, x: 0, y: 5)

在这个例子中,Text 视图应用了多个内置修饰符,改变了字体、颜色、背景、圆角和阴影效果。

如何自定义视图

除了使用内置视图和修饰符,SwiftUI 允许开发者创建自定义视图。通过组合多个视图和修饰符,可以创建可重用且模块化的 UI 组件。

创建自定义视图的步骤

  1. 定义新的视图结构:创建一个遵循 View 协议的结构体。
  2. 实现 body 属性:在 body 中定义视图的内容和布局。
  3. 组合视图和修饰符:使用内置视图和修饰符来设计自定义视图。

示例:创建一个自定义按钮视图

1
import SwiftUIstruct CustomButton: View {    var title: String    var backgroundColor: Color    var body: some View {        Text(title)            .font(.headline)            .foregroundColor(.white)            .padding()            .background(backgroundColor)            .cornerRadius(8)            .shadow(radius: 5)    }}struct ContentView: View {    var body: some View {        CustomButton(title: "Click Me", backgroundColor: .green)    }}

在这个示例中,CustomButton 是一个自定义视图,通过组合 Text 视图和多个修饰符,创建了一个带有背景色、圆角和阴影效果的按钮。

视图与组件化开发

组件化开发 是现代软件开发中的一种最佳实践,通过将 UI 分解为可重用的组件,提升代码的可维护性和可扩展性。在 SwiftUI 中,视图本身就是组件的基本形式,开发者可以通过创建自定义视图来实现组件化开发。

组件化的优势

  • 可重用性:相同的组件可以在不同的地方重复使用,减少代码冗余。
  • 可维护性:组件独立,便于管理和更新。
  • 可测试性:独立的组件更容易进行单元测试。

示例:创建多个自定义组件

1
struct HeaderView: View {    var title: String    var body: some View {        Text(title)            .font(.largeTitle)            .padding()            .background(Color.orange)            .foregroundColor(.white)    }}struct FooterView: View {    var body: some View {        Text("© 2025 Your Company")            .font(.footnote)            .padding()            .background(Color.gray.opacity(0.2))    }}struct ContentView: View {    var body: some View {        VStack {            HeaderView(title: "Welcome to My App")            Spacer()            FooterView()        }    }}

在这个示例中,HeaderViewFooterView 是两个独立的自定义组件,通过组合它们构建了一个完整的界面。

MVVM 架构简介

MVVM(Model-View-ViewModel) 是一种软件架构模式,旨在将应用程序的业务逻辑与用户界面分离。MVVM 提供了一种清晰的方式来组织代码,使其更易于维护、测试和扩展。

MVVM 的组成部分

  • Model(模型):表示应用程序的数据和业务逻辑。通常与数据存储和网络请求相关。
  • View(视图):负责显示数据和处理用户交互。SwiftUI 中的视图如 TextImage 等。
  • ViewModel(视图模型):充当 Model 和 View 之间的中介。它处理业务逻辑,将 Model 中的数据转换为 View 可以直接使用的格式,并响应用户交互。

MVVM 的优势

  • 分离关注点:将 UI 与业务逻辑分离,提升代码的可维护性。
  • 可测试性:ViewModel 可以独立于 View 进行单元测试。
  • 可重用性:ViewModel 可以在多个 View 中复用。

SwiftUI 中的 MVVM 实现

在 SwiftUI 中,MVVM 架构通过结合 SwiftUI 的声明式语法和数据绑定机制,实现了 Model、View 和 ViewModel 的分离与协作。

关键概念

  • @State:用于在 View 中存储和管理局部状态。
  • @ObservableObject:用于声明一个可观察的对象,通常在 ViewModel 中使用。
  • @Published:用于在 ObservableObject 中标记可以被观察的属性。
  • @StateObject@ObservedObject:用于在 View 中引用 ViewModel。

示例:使用 MVVM 架构的简单计数器应用

Model

1
struct CounterModel {    var count: Int = 0}

ViewModel

1
import Combineclass CounterViewModel: ObservableObject {    @Published var counter: CounterModel    init(counter: CounterModel = CounterModel()) {        self.counter = counter    }    func increment() {        counter.count += 1    }    func decrement() {        counter.count -= 1    }}

View

1
import SwiftUIstruct CounterView: View {    @StateObject private var viewModel = CounterViewModel()    var body: some View {        VStack(spacing: 20) {            Text("Count: \(viewModel.counter.count)")                .font(.largeTitle)            HStack(spacing: 40) {                Button(action: {                    viewModel.decrement()                }) {                    Text("-")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.red)                        .foregroundColor(.white)                        .clipShape(Circle())                }                Button(action: {                    viewModel.increment()                }) {                    Text("+")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.green)                        .foregroundColor(.white)                        .clipShape(Circle())                }            }        }        .padding()    }}

在这个示例中:

  • ModelCounterModel 包含一个 count 属性,表示当前计数值。
  • ViewModelCounterViewModel 作为 ObservableObject,管理 CounterModel 的实例,并提供 incrementdecrement 方法来修改计数值。
  • ViewCounterView 使用 @StateObject 引用 CounterViewModel,并通过数据绑定 (viewModel.counter.count) 显示计数值,同时通过按钮调用 ViewModel 的方法来修改计数。

MVVM 架构关系图

以下图示展示了 MVVM 架构在 SwiftUI 中的关系:

1
graph LR    Model --> ViewModel    ViewModel --> View    View --> ViewModel
  • Model:提供数据和业务逻辑。
  • ViewModel:持有 Model 的实例,处理业务逻辑,并通过 @Published 属性将数据暴露给 View。
  • View:通过 @StateObject@ObservedObject 引用 ViewModel,使用数据绑定显示数据,并通过用户交互调用 ViewModel 的方法。

基于SwiftUI的项目如何设置MVVM

在 SwiftUI 项目中应用 MVVM 架构,可以按照以下步骤进行设置:

1. 创建 Model

首先,定义应用程序的数据结构和业务逻辑。例如,一个简单的用户模型:

1
struct User: Identifiable {    let id: UUID    let name: String    let email: String}

2. 创建 ViewModel

创建一个 ObservableObject 类来管理 Model 的数据和业务逻辑:

1
import Combineclass UserViewModel: ObservableObject {    @Published var users: [User] = []    func fetchUsers() {        // 模拟网络请求或数据获取        users = [            User(id: UUID(), name: "Alice", email: "alice@example.com"),            User(id: UUID(), name: "Bob", email: "bob@example.com")        ]    }    func addUser(name: String, email: String) {        let newUser = User(id: UUID(), name: name, email: email)        users.append(newUser)    }    func removeUser(at offsets: IndexSet) {        users.remove(atOffsets: offsets)    }}

3. 创建 View

在 View 中引用 ViewModel 并使用数据绑定展示和操作数据:

1
import SwiftUIstruct UserListView: View {    @StateObject private var viewModel = UserViewModel()    var body: some View {        NavigationView {            List {                ForEach(viewModel.users) { user in                    VStack(alignment: .leading) {                        Text(user.name)                            .font(.headline)                        Text(user.email)                            .font(.subheadline)                            .foregroundColor(.gray)                    }                }                .onDelete(perform: viewModel.removeUser)            }            .navigationTitle("用户列表")            .navigationBarItems(trailing: Button(action: {                viewModel.addUser(name: "新用户", email: "newuser@example.com")            }) {                Image(systemName: "plus")            })            .onAppear {                viewModel.fetchUsers()            }        }    }}

4. 组织项目结构

为了更好地组织代码,建议按照 MVVM 的结构将项目分为不同的文件夹:

1
YourProject/├── Models/│   └── User.swift├── ViewModels/│   └── UserViewModel.swift├── Views/│   └── UserListView.swift└── YourProjectApp.swift

5. 使用依赖注入(可选)

为了提高代码的可测试性和可扩展性,可以使用依赖注入将 ViewModel 注入到 View 中。例如:

1
struct UserListView: View {    @ObservedObject var viewModel: UserViewModel    var body: some View {        // 与之前相同    }}// 在入口处注入 ViewModelstruct YourProjectApp: App {    var body: some Scene {        WindowGroup {            UserListView(viewModel: UserViewModel())        }    }}

6. 添加更多功能

随着项目的发展,可以在 ViewModel 中添加更多的方法和属性,处理更复杂的业务逻辑,同时保持 View 的简洁和专注于展示。

SwiftUI 术语汇总

为了更好地理解 SwiftUI,以下是一些常见的术语及其解释:

  • 声明式编程(Declarative Programming):一种编程范式,开发者描述 UI 应该是什么样子,系统负责管理其状态和更新。
  • 视图(View):SwiftUI 中的基本构建块,用于构建用户界面。
  • 修饰符(Modifier):用于修改视图属性的方法,采用链式调用方式。
  • 布局容器(Layout Containers):用于组织和布局视图的容器,如 VStackHStackZStack
  • 状态(State):视图的数据源,影响视图的显示和行为。
  • 绑定(Binding):一种双向数据流机制,使视图与其数据源保持同步。
  • 环境(Environment):提供全局数据和配置,视图可以从环境中读取或写入数据。
  • 预览(Preview):Xcode 提供的实时预览功能,允许开发者在编写代码时即时查看 UI 的变化。
  • ObservableObject:一种可以被多个视图观察的对象,当其中的 @Published 属性发生变化时,视图会自动更新。
  • @State:用于在视图内部存储和管理局部状态。
  • @ObservedObject:用于在视图中观察外部的 ObservableObject,视图会在对象变化时自动更新。
  • @EnvironmentObject:用于在多个视图中共享一个 ObservableObject,无需手动传递。

总结

SwiftUI 以其声明式语法和强大的功能,正在迅速改变 iOS 和其他 Apple 平台的应用开发方式。理解 SwiftUI 的核心概念,如视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图和组件化开发,是掌握这一框架的关键。通过引入 MVVM 架构,开发者可以进一步提升代码的可维护性和可测试性,构建出高效、可扩展的应用。

本文介绍了 SwiftUI 的基本概念和术语,展示了如何创建和使用自定义视图,并深入探讨了 MVVM 架构在 SwiftUI 中的实现方式。掌握这些知识后,你将能够在 SwiftUI 项目中应用最佳实践,打造出优雅且高效的用户界面。

继续深入学习 SwiftUI 的高级功能,如动画、数据绑定和自定义控件,将进一步提升你的开发技能,帮助你打造出更具吸引力和互动性的应用。

留言與分享

SwiftUI文档

分類 编程语言, SwiftUI

SwiftUI简介

SwiftUI是一种更为现代的编码方式,他可以为苹果任意平台声明用户界面。以更为快捷迅速的方式创建漂亮的动态应用程序!

只需描述你的布局

为视图的任何状态声明内容和布局。SwiftUI知道该状态何时更改,并更新视图,以匹配其呈现的状态。

1
2
3
4
5
6
7
8
9
10
11
12
List(landmarks) { landmark in
HStack {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}

构建可重用组件

将小的、单一职责的视图组合成更大、更复杂的界面。在为苹果任意平台设计的应用程序之间共享您的自定义视图。

1
2
3
4
5
6
7
8
9
10
struct FeatureCard: View {
var landmark: Landmark

var body: some View {
landmark.featureImage
.resizable()
.aspectRatio(3/2, contentMode: .fit)
.overlay(TextOverlay(landmark))
}
}

简化动画

创建平滑动画就像添加一个方法调用一样简单。SwiftUI会在需要时自动计算并设置过渡动画。

1
2
3
4
5
6
7
8
VStack {
Badge()
.frame(width: 300, height: 300)
.animation(.easeInOut())
Text(name)
.font(.title)
.animation(.easeInOut())
}

在Xcode中实时预览

在不运行应用程序的情况下设计、生成和测试应用程序的界面。使用交互式预览测试控件和布局。

应用程序设计和布局

组合复杂界面

地标的首页显示一个滚动的分类列表,在每个分类中水平滚动地标。在构建此主导航时,您将探索组合视图如何适应不同的设备大小和方向。

学习时间:20分钟

下载地址:ComposingComplexInterfaces.zip

第一节 添加首页

既然你已经拥有了地标应用程序所需的所有视图,现在是时候给它们一个首页了——一个统一视图页面。主视图不仅包含所有其他视图,还提供导航和显示地标的方法。

步骤1

在名为Home.swift的新文件中创建名为CategoryHome的自定义视图。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CategoryHome: View {
var body: some View {
Text("Landmarks Content")
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

修改scene delegate,使其显示新的CategoryHome视图而不是地标列表。

主视图是地标应用程序的根,因此它需要一种方式来呈现所有其他视图。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: CategoryHome()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}
}

步骤3

添加NavigationView以在地标中承载不同的视图。

您可以使用NavigationView以及NavigationLink实例和相关修饰符在应用程序中构建导航层级结构。

Home.swift

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

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

将导航栏的标题设置为“Featured”。

Home.swift

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

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第二节 创建分类列表

Landmarks应用程序将所有分类显示在垂直列中的单独行中,以便于浏览。通过组合垂直和水平堆栈,并向列表中添加滚动条,就可以完成此操作。

步骤1

使用Dictionary结构的init(grouping:by:)方法将地标分组到类别中,输入地标的category属性。

初始化项目文件给每个示例地标预设了类别。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

使用列表在地标中显示分类。

Landmark.Category会匹配列表中每一项name,这些项在其他类别中必须是唯一的,因为它是枚举。

Home.swift

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
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
Text(key)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第三节 添加地标行

地标在水平滚动的行中显示每个类别。添加一个新的视图类型来表示行,然后在新视图中显示该类别的所有地标。

步骤1

定义用于保存行内容的新自定义视图。

这个视图需要存储地标的类别信息,以及地标本身。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
Text(self.categoryName)
.font(.headline)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤2

更新CategoryHome的主体以将类别信息传递给新的行类型。

Home.swift

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
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

HStack中显示该类别的地标。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤4

通过指定一个frame(width:height:) 并在scrollView中包装stack,为行提供空间。

使用长数据样本更新视图预览,以便确保可以滚动。

CategoryRow.swift

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
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

第四节 组成主页

地标应用程序的主页需要显示地标的简单信息,然后用户点击其中一个更多进入详情视图。

重新使用在Creating and Combining Views中的视图来创建更简单的视图预览,以显示地标的分类和特征。

步骤1

CategoryRow旁边创建一个名为CategoryItem的新自定义视图,并用新视图替换Text视图。

CategoryRow.swift

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
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
CategoryItem(landmark: landmark)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤2

Home.swift中,添加一个名为FeaturedLandmarks的简单视图,该视图仅显示标记为isFeatured的地标。

在后面的教程中,您将把此视图转换为可交互的轮播视图。目前,它显示了一个裁剪和缩放后的预览图像。

Home.swift

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
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

在两种地标预览中将edge insets设置为零,以便内容可以延伸到屏幕边缘。

Home.swift

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
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第五节 在分区之间添加导航

现在,在主页中可以看到所有不同分类的地标,用户需要一种方法来访问应用程序中的每个部分。使用navigationpresentation Apis可以从主页导航到地标详情页,收藏夹和用户主页。

步骤1

CategoryRow.swift中,用NavigationLink包装现有的CategoryItem

类别项本身是按钮的label,其目的地是显示地标详情视图。

CategoryRow.swift

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
51
52
53
54
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

注意

Xcode11 beta 6中,如果在列表中嵌套一个ScrollView,并且该ScrollView包含一个NavigationLink,那么当用户点击NavigationLink时,这些NavigationLink不会导航到目标视图。

步骤2

通过应用renderingMode(:)foregroundColor(:)修饰符更改导航外观。

作为NavigationLinklabel传递的文本使用环境的强调色进行渲染,而图像可能被作为template images进行渲染。您可以修改任一行为以最适合您的设计。

CategoryRow.swift

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
51
52
53
54
55
56
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.renderingMode(.original)
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.foregroundColor(.primary)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤3

Home.swift中,点击选项卡栏中的简介图标后,添加一个模态视图以显示用户简介页面。

showProfile状态变量设置为true时,SwiftUI显示用户简介占位符,当用户关闭模态时,将showProfile设置回false

Home.swift

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
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

在导航栏中添加一个按钮,在点击时将showProfilefalse切换为true

Home.swift

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
51
52
53
54
55
56
57
58
59
60
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
.init(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image(forSize: 250).resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤5

通过添加导航链接完成主页,该链接指向可过滤所有地标的列表。

Home.swift

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤6

LandmarkList.swift中,删除包装地标列表的NavigationView,并将其添加到预览中。

在应用程序的环境中,LandmarkList将始终显示在Home.swift中声明的导航视图中。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {

List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))

}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LandmarkList()
.environmentObject(UserData())
}
}
}

SwiftUI纲要

创建和组织视图

本教程将指导您构建一个iOS应用程序——Landmarks(地标),用于发现和分享您喜欢的地方。首先,您将构建显示Landmark的详情页面。

为了布局视图,"地标"使用stacks来对image、text等组件进行组织和分层。要将地图添加到视图中,需要包含一个标准的MapKit组件。当您优化视图的设计时,Xcode提供实时反馈,这样当您修改代码时,就可以看到视图状态的改变。

下载项目文件开始构建此项目,并按照以下步骤操作:

学习时长:40分钟

下载示例:CreatingAndCombiningViews.zip

第一节 创建新项目并浏览画布

创建一个使用SwiftUI的新Xcode项目。浏览画布、预览和SwiftUI模板代码。

要在Xcode中预览画布上的视图并与之交互,请确保Mac运行的是macOS Catalina 10.15。

步骤一

打开Xcode并在Xcode的启动窗口中单击Create a new Xcode项目,或者选择File>new>project

步骤二

在模板选择器中,选择iOS平台,选择单视图应用程序模板,然后单击下一步。

步骤三

输入“Landmarks”作为产品名称,选中Use SwiftUI复选框,然后单击Next。选择一个路径来保存你的项目。

步骤四

在项目导航器中,单击以选择ContentView.swift。

默认情况下,SwiftUI视图文件声明两个结构。第一个结构实现了视图的必选协议,并描述了视图的内容和布局。第二个结构声明了该视图的预览。

ContentView.swift

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

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

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

在画布中,单击Resume以显示预览。

Tips:如果画布不可见,请选择Editor > Editor and Canvas以显示它。

步骤六

body属性中,将“Hello World”更改为自己的问候语。

当您在视图的body属性中更改代码时,预览将更新以反映您的更改。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第二节 自定义文本视图

您可以通过更改代码,或使用检查器查看可用的内容,并帮助您编写代码,来自定义视图的显示。

构建Lanmarks应用程序时,可以使用任意编辑器组合:源码编辑、画布或检查器。无论使用哪种工具,代码都会保持更新。

接下来,您将使用检查器自定义文本视图。

步骤一

在预览中,按住command并单击问候语以打开结构化编辑弹出窗口,然后选择Inspect

弹出窗口显示可自定义的不同属性,具体取决于所检查的视图类型。

步骤二

使用检查器将文本更改为“Turtle Rock”,即您将在应用程序中显示的第一个地标的名称。

步骤三

font更改为Title

这会将系统字体应用于该文本,以便它正确适应用户系统偏好的字体大小和设置。

要自定义SwiftUI视图,可以调用名为修饰符(modifier)的方法。修饰符会包装视图以更改其显示或其他属性。每个修饰符都返回一个新视图,因此通常采用垂直的链式调用多个修饰符。

步骤四

手动编辑代码添加foregroundColor(.green)修饰符;这会将文本的颜色更改为绿色。

ContentView.swift

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

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)
.foregroundColor(.green)
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

您的代码始终真实反应到视图上。因此当使用检查器更改或移除修饰符时,Xcode会立即更新视图以匹配代码。

步骤五

然后我们在代码编辑区,按住command并单击Text来打开检查器,然后从弹出窗口中选择Inspect。单击Color弹出菜单,然后选择Inherited , 将文本颜色再次更改为黑色。

步骤六

请注意,Xcode会自动更新代码以反映您的更改,并删除foregroundColor(.green)修饰符。

ContentView.swift

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

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)

}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第三节

使用堆栈组合视图

除了在上一节中创建的标题视图之外,还将添加文本视图,以包含有关地标的详细信息,例如公园的名称和所在的州。

创建SwiftUI视图时,可以在视图的body属性中描述其内容、布局和行为;但是,body属性只返回单个视图。您可以将多个视图组合并嵌入到Stack中,从而将视图水平、垂直或前后组合在一起。

在本节中,您将使用VStack(垂直堆栈)组合标题信息,使用HStack(水平堆栈)组合公园详情信息。

你可以使用Xcode的结构化编辑功能在容器视图中嵌入视图,也可以打开Inspector 或使用help进行其他更多有用的更改。

步骤一

按住command并单击Text视图,会看到初始值设定项的显示结构化编辑弹出窗口,然后选择Embed in VStack

接下来,通过从组件库中拖动Text视图,将Text视图添加到Stack中。

通过单击Xcode窗口右上角的加号按钮(+)打开组件库,然后将Text视图拖到Turtle Rock文本视图下面的位置。

步骤三

Text视图的默认文本替换为“Joshua Tree National Park”。

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

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

自定义位置文本的样式,以满足布局需求。

步骤四

将位置的font设置为subheadline

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

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

编辑VStack初始值设定项,以leading方式对齐视图。

默认情况下,VStack会将它们的内容按轴中心对齐,并提供上下文适当的间距。

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

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

接下来,您将在位置文本的右侧添加另一个文本视图,该视图用于展示公园所在的州。

步骤六

在画布中,按住command并单击Joshua Tree National Park,然后选择Embed In HStack

步骤七

在地点文本后添加新的文本视图,将默认文本更改为公园所在的州,然后将其font设置为subheadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

若想使布局自动撑开并充满设备的宽,在HStack中添加Spacer来分隔Joshua Tree National ParkCalifornia两个文本视图。

Spacer将撑开Stack,以使其包含的视图充满其父视图的所有空间,而不是仅由其内容定义其大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤九

最后,使用padding()修饰符方法给整个地标信息区域加一个边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第四节

创建自定义图像视图

在名称和地点视图都设置好的情况下,接下来要做的是为地标添加一个图像。

对自定义的图像使用遮罩、边框和阴影,和原来的方法相比,将使用很少的代码。

首先将图片添加到项目的Assets中。

步骤一

在项目Resources文件夹的中找到turtlerock.png,将其拖到Assets中。Xcode为图像创建一个新的图像集。

接下来,您将为您的自定义图像视图创建一个新的SwiftUI视图。

步骤二

选择File > New > File再次打开模板选择器。在User Interface中,单击选择SwiftUI View,然后单击Next。将文件命名为CircleImage.swift,然后单击Create

现在您已准备好图像,以满足设计所需。

步骤三

使用Image(:)Text视图替换为Turtle Rock的图像视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤四

添加Image.clipShape(Circle())的修饰符,将图像剪裁为圆形。

Circle()是一个可以用作遮罩的形状,或通过给Circle()设置strokefill来绘制视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤五

创建另一个带有灰色strokeCircle(),使用.overlay()修饰符,将其覆盖添加到图像的边框中。

CircleImage.swift

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

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤六

接下来,添加半径为10point的阴影。

CircleImage.swift

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

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤七

将边框颜色修改为白色。

到此,我们就完成了图像视图。

CircleImage.swift

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

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

第五节

UIKit和SwiftUI配合使用

现在可以创建地图视图了,您可以使用MapKit中的MKMappView类来绘制地图。

要在SwiftUI中使用UIView子类,可以将另一个视图包装在遵守UIViewRepresentable协议的SwiftUI视图中。SwiftUI包含了WatchKit和AppKit视图类似的协议。

现在开始,您将创建一个新的自定义视图,该视图可以显示MKMapView

选择File > New > File,选择iOS平台,选择SwiftUI View模板,然后单击Next。将新文件命名为MapView.swift,然后单击“Create”。

步骤二

添加import MapKit,并让MappView结构遵守UIViewRepresentable协议。

不要担心Xcode显示的警告;您将在接下来的几个步骤中修复它。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

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

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

UIViewRepresentable协议有两个需要实现的方法:使用UIView(context:)方法创建MKMapView,使用updateUIView(u:context:)方法来配置视图并响应视图的任意变化。

步骤三

makeUIView(context:)方法替换body属性。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤四

创建一个updateUIView(u:context:)方法,将地图视图的区域设置为正确的坐标,以便将地图居中放置在Turtle Rock上。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let coordinate = CLLocationCoordinate2D(
latitude: 34.011286, longitude: -116.166868)
let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

当预览处于静态模式时,它们仅完全呈现SwiftUI视图。因为MKMapView是一个UIView子类,所以您需要切换到实时预览来查看地图。

步骤五

单击Live Preview按钮将预览切换到实时预览模式。您可能需要单击预览上方的Try AgainResume按钮。

再过一会儿,你会看到一张Joshua Tree National Park的地图,那里是Turtle Rock的故乡。

第六节

组成详情视图

现在您已经拥有了所需的所有组件——名称和位置、圆形图像以及地图。

现在使用目前的工具,组合自定义视图,创建地标详情视图以达到最终设计吧。

步骤一

在项目导航器中,选择ContentView.swift文件。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤二

将刚才的三个文本的VStack嵌入到另一个VStack中。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤三

将自定义MapView添加到Stack顶部。使用frame(width:height:)设置MapView的大小。

仅指定height(高度)参数时,视图会自动调整其内容的宽度。在这种情况下,MapView将展开以填充可用空间。

步骤四

单击Live Preview按钮,以在预览中查看渲染的地图。

您可以在显示实时预览时继续编辑视图。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

CircleImage视图添加到Stack中。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤六

若要将CircleImage显示在MapView视图的上方,请使图像垂直偏移-130个点,并从视图底部填充-130个点。

这些调整通过向上移动图像为文本腾出空间。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤七

VStack中底部添加一个Spacer(),将内容推到屏幕的顶部。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

最后,要允许地图内容扩展到屏幕的上边缘,请将edgesIgnoringSafeArea(.top)修饰符添加到地图视图中。

ContentView.swift

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
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

绘图和动画

绘制路径和形状

用户每次访问列表中的地标时都会收到徽章。当然,要让用户收到徽章,您需要创建一个徽章。本教程将引导您通过组合路径和形状来创建徽章,然后将其与表示位置的另一个形状重叠。

如果要为不同类型的地标创建多个徽章,请尝试使用覆盖的符号、更改重复次数或更改不同角度和比例。

学习时间:25分钟

下载地址:DrawingPathsAndShapes.zip

第一节 创建徽章视图

要创建徽章,首先要创建一个徽章视图,该视图使用SwiftUI中的矢量绘图api

步骤1

选择File > New > File,然后从“iOS模板”工作表中选择SwiftUI View。单击Next继续,然后在“Save as”字段中输入Badge并单击“Create”。

步骤2

调整Badge视图以显示文本“Badge”,接下来我们开始定义徽章形状。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
Text("Badge")
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第二节 绘制徽章背景

使用SwiftUI中的图形api绘制自定义徽章形状。

步骤1

查看HexagonParameters.swift文件中的代码。

六边形参数结构定义了绘制徽章六边形的详细信息。您不会修改此数据;相反,您将使用它指定用于绘制徽章的线条和曲线的控制点。

HexagonParameters.swift

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
51
import SwiftUI

struct HexagonParameters {
struct Segment {
let useWidth: (CGFloat, CGFloat, CGFloat)
let xFactors: (CGFloat, CGFloat, CGFloat)
let useHeight: (CGFloat, CGFloat, CGFloat)
let yFactors: (CGFloat, CGFloat, CGFloat)
}

static let adjustment: CGFloat = 0.085

static let points = [
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.60, 0.40, 0.50),
useHeight: (1.00, 1.00, 0.00),
yFactors: (0.05, 0.05, 0.00)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.05, 0.00, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.20 + adjustment, 0.30 + adjustment, 0.25 + adjustment)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.00, 0.05, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.70 - adjustment, 0.80 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.40, 0.60, 0.50),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.95, 0.95, 1.00)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.95, 1.00, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.80 - adjustment, 0.70 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (1.00, 0.95, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.30 + adjustment, 0.20 + adjustment, 0.25 + adjustment)
)
]
}

步骤2

Badge.swift中,向Badge添加一个Path shape,并应用fill()修饰符将该形状转换为视图。

您可以使用paths来组合线条、曲线和其他绘图,以形成更复杂的形状,如徽章的六边形背景。

Badge.swift

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

struct Badge: View {
var body: some View {
Path { path in

}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

path添加起点。

move(to:)方法在形状的边界内移动绘图光标,就像一支虚构的笔悬停在该区域上,等待开始绘图一样。

Badge.swift

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

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

为形状数据的每个点绘制线,以创建一个大致的六边形。

addLine(to:)方法接受一个点并绘制它。对addLine(to:)连续调用,会从上一点开始到新点画一条线。

Badge.swift

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
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

如果你的六边形看起来有点不寻常,不要担心;这符合预期。在接下来的几个步骤中,您将努力使六边形看起来更像本节开头显示的徽章形状。

步骤5

使用addQuadCurve(to:control:)方法为徽章的角绘制Bézier曲线。

Badge.swift

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
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤6

将徽章path包装在GeometryReader中,以便徽章可以使用其包含视图的大小,该视图定义大小,而不是硬编码值(100)。

当包含的视图不是正方形时,使用几何体的两个维度中的最小值以保证徽章的宽高比。

Badge.swift

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
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤7

使用xScalexOffset调整变量使徽章在其几何体中居中。

Badge.swift

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
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤8

将徽章的纯黑背景替换为与设计匹配的渐变色。

Badge.swift

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
51
52
53
54
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤9

对渐变填充应用aspectRatio(u:contentMode:)修饰符。

即使其父视图不是正方形的,可以通过它可以使视图保持1:1的宽高比,并保持其在视图中心的位置。

Badge.swift

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
51
52
53
54
55
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第三节 画徽章符号

地标徽章的中心有一个自定义徽章,该徽章基于地标应用程序图标中显示的山。

山体符号由两个形状组成:一个表示山顶的积雪,另一个表示沿引道的植被。您将使用两个由一个小间隙隔开的部分三角形来绘制它们。

步骤1

在名为BadgeBackground.swift的新文件中,将徽章视图的主体放入新的徽章背景视图中,以便为其他视图准备徽章视图。

BadgeBackground.swift

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
51
52
53
54
55
import SwiftUI

struct BadgeBackground: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct BadgeBackground_Previews: PreviewProvider {
static var previews: some View {
BadgeBackground()
}
}

步骤2

将徽章背景放在徽章主体中以恢复徽章。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
BadgeBackground()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

为山形创建一个名为BadgeSymbol的新自定义视图,该山形在徽章设计中以旋转方式绘制。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
Text("Badge Symbol")
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤4

使用Paths api绘制符号的顶部。

实验

尝试调整与spacing、topWidth和topHeight常量关联的系数,以查看它们如何影响整体形状。

BadgeSymbol.swift

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
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤5

绘制符号的底部。

使用move(to:) 修饰符在同一路径中的多个形状之间插入间隙。

BadgeSymbol.swift

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
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤6

用设计中的紫色填充符号。

BadgeSymbol.swift

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
import SwiftUI

struct BadgeSymbol: View {
static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)

var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
.fill(Self.symbolColor)
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

第四节 结合徽章前景和背景

徽章设计要求在徽章背景上旋转和重复多次山形。

定义一种新的rotation类型,并利用ForEach视图对山形的多个副本使用相同的调整。

步骤1

创建新的RotatedBadgeSymbol视图以封装旋转符号。

实验

调整预览中的角度以测试旋转的效果。

RotatedBadgeSymbol.swift

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

struct RotatedBadgeSymbol: View {
let angle: Angle

var body: some View {
BadgeSymbol()
.padding(-60)
.rotationEffect(angle, anchor: .bottom)
}
}

struct RotatedBadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
RotatedBadgeSymbol(angle: .init(degrees: 5))
}
}

步骤2

Badge.swift中,将徽章的符号放置在ZStack中,将其放置在徽章背景上。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

self.badgeSymbols
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

现在看来,徽章符号与预期的设计和背景的相对大小相比太大了。

步骤3

通过读取周围的几何图形并缩放符号,更正徽章符号的大小。

Badge.swift

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
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

使用ForEach创建旋转显示多个徽章符号的副本。

一个完整的360°旋转分成八个部分,通过重复山脉符号创建一个类似太阳的图案。

Badge.swift

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
import SwiftUI

struct Badge: View {
static let rotationCount = 8

var badgeSymbols: some View {
ForEach(0..<Badge.rotationCount) { i in
RotatedBadgeSymbol(
angle: .degrees(Double(i) / Double(Badge.rotationCount)) * 360.0
)
}
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
.scaledToFit()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

框架集成

与UIKit协作

SwiftUI与所有苹果平台上现有的UI框架无缝协作。例如,可以将UIKit视图和视图控制器放置在SwiftUI视图中,反之亦然。

本教程向您展示如何将主页的特色地标转换为包装UIPageViewController和UIPageControl实例。您将使用UIPageViewController来显示SwiftUI视图的轮播,并使用状态变量和绑定来协调整个用户界面中的数据更新。

学习时间:25分钟

下载地址:InterfacingWithUIKit.zip

第一节

创建显示UIPageViewController的视图

要在SwiftUI中显示UIKit视图和视图控制器,可以创建遵守UIViewRepresentableUIViewControllerRepresentable协议的类型,您的自定义类型创建并配置它们所表示的UIKit类型,而SwiftUI管理它们的生命周期,并在需要时更新它们。

步骤1

创建一个名为PageViewController.swift的新SwiftUI视图文件,并声明PageViewController类型遵守UIViewControllerRepresentable

页面视图控制器存储UIViewController实例的数组。这些是在地标之间滚动的页面。

PageViewController.swift

1
2
3
4
5
6
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
}

接下来,添加UIViewControllerRepresentable协议的两个方法。

步骤2

添加makeUIViewController(context:)方法,该方法创建满足需求的UIPageViewController

SwiftUI在准备好显示视图时调用此方法一次,然后管理视图控制器的生命周期。

PageViewController.swift

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

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}
}

步骤3

添加一个updateUIViewController(_:context:)方法,该方法调用setViewControllers(_:direction:animated:)以显示数组中的第一个视图控制器。

PageViewController.swift

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

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}
}

创建另一个SwiftUI视图以显示UIViewControllerRepresentable视图。

步骤4

创建一个名为PageView.swift的新SwiftUI视图文件,并更新PageView类型以将PageViewController声明为子视图。

注意,泛型初始值设定项接受一个视图数组,并将每个视图嵌套在UIHostingController中。UIHostingControllerUIViewController子类,表示UIKit上下文中的SwiftUI视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView()
}
}

步骤5

更新PageView_Preview以传递所需的视图数组,此时预览开始工作。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤6

在继续下一步之前,将页面视图预览固定到画布,所有操作变化都会发生在此视图上。

第二节 创建视图控制器的数据源

在几个简单的步骤中,您已经完成了很多工作—PageViewController使用一个UIPageViewController在SwiftUI视图中显示内容。现在是时候让视图轮播滚动了。

代表UIKit视图控制器的SwiftUI视图可以定义一个``类型,SwiftUI将其作为可表示视图上下文的一部分进行提供和管理。

步骤1

PageViewController中声明嵌套的Coordinator类。

SwiftUI管理UIViewControllerRepresentable类型的Coordinator,并在调用上面创建的方法时将其作为上下文的一部分提供。

PageViewController.swift

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
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤2

PageViewController添加另一个方法以生成Coordinator

SwiftUI在makeUIViewController(context:)之前调用此makeCoordinator()方法,以便在配置视图控制器时可以访问coordinator对象。

Tips 您可以使用这个Coordinator来实现常见的Cocoa模式,例如委托、数据源和通过target-action响应用户事件。

PageViewController.swift

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
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤3

Coordinator类型添加UIPageViewControllerDataSource一致性,并实现两个必需的方法。

这两个方法建立视图控制器之间的关系,以便您可以在它们之间来回滑动。

PageViewController.swift

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
51
52
53
54
55
56
57
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤4

coordinator添加为UIPageViewController的数据源。

PageViewController.swift

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
51
52
53
54
55
56
57
58
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤5

打开实时预览并测试滑动交互。

第三节 在SwiftUI视图状态下跟踪页面

要准备添加自定义UIPageControl,需要一种方法从PageView中跟踪当前页。

为此,您将在PageView中声明@State属性,并将对该属性的绑定传递到PageViewController视图。PageViewController更新绑定以匹配当前可见页。

步骤1

首先添加一个currentPage绑定作为PageViewController的属性。

除了声明@Binding属性外,还要更新对setViewControllers(u:direction:animated:)的调用,传递currentPage绑定的值。

PageViewController.swift

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
51
52
53
54
55
56
57
58
59
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤2

PageView中声明@State变量,并在创建子PageViewController时向属性传递绑定。

重要

请记住使用$语法创建用状态来存储值的绑定。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤3

通过更改PageViewController的初始值,测试该值是否通过绑定流向PageViewController

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

实验:向PageView添加一个按钮,使PageViewController跳转到第二个视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤4

添加一个带有currentPage属性的文本视图,这样您就可以监视@State属性的值。

请注意,当您从一页刷到另一页时,值不会改变。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
VStack {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
Text("Current Page: \(currentPage)")
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

步骤5

PageViewController.swift中,将协调器设置为UIPageViewControllerDelegate,并添加pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool) 方法。

由于SwiftUI在页面切换动画完成时调用此方法,因此可以找到当前视图控制器的索引并更新绑定。

PageViewController.swift

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

步骤6

除了dataSource之外,还将coordinator指定为UIPageViewController的委托。

当在两个方向上连接绑定后,文本视图会在每次刷新后更新以显示正确的页码。

PageViewController.swift

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

第四节 添加自定义分页控件

您已经准备好向视图中添加自定义UIPageControl,该控件包装在SwiftUIUIViewRepresentable视图中。

步骤1

创建一个新的SwiftUI视图文件,名为PageControl.swift。更新PageControl类型以遵守UIViewRepresentable协议。

UIViewRepresentableUIViewControllerRepresentable类型具有相同的生命周期,其方法与其基础的UIKit类型相对应。

PageControl.swift

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

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
}

步骤2

将文本框替换为PageControl,从VStack切换到ZStack进行布局。

因为我们正在讲页数和绑定传递到当前页,所以PageControl已显示正确的值。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
.padding(.trailing)
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

下一步,让PageControl交互,这样用户可以点击一边或另一边时,页面可以随之滚动。

步骤3

PageControl中创建嵌套的Coordinator类型,并添加makeCoordinator()方法以创建并返回新的协调器。

由于UIPageControlUIControl子类,故使用target-action模式而不是代理,所以此Coordinator实现@objc方法来更新 current page的绑定。

PageControl.swift

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
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤4

Coordinator添加为valueChanged事件的目标,指定updateCurrentPage(sender:)方法作为要执行的操作。

PageControl.swift

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 SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤5

现在尝试所有不同的交互,PageView展示了UIKit和SwiftUI视图以及控制器是如何协同工作的。

绘图和动画

视图动画和转场

使用SwiftUI时,无论效果在哪里,都可以对视图或视图状态的更改单独设置动画。SwiftUI负责处理这些组合、重叠和可中断动画的所有复杂性。

在本教程中,您将设置一个视图的动画,该视图包含一个图形,用于跟踪用户在使用Landmarks应用程序时的行为数据。使用animation(_:)修改器,您将看到设置视图动画是多么容易。

学习时间:25分钟

下载地址:AnimatingViewsAndTransitions.zip

第一节 向各个视图添加动画

在视图上使用animation(_:)修改器时,SwiftUI可以改变视图的任何animatable(可动画)属性。视图的颜色color、不透明度opacity、旋转rotation、大小size和其他属性都是可设置动画的。

步骤1

HikeView.swift中,打开实时预览并尝试显示和隐藏图形。

请确保在本教程中使用实时预览,以便您可以尝试每个步骤的结果。

步骤2

通过添加animation(.easeInOut())为按钮启用旋转动画。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

当图形可见时,添加一个让按钮变大的动画。

animation(_:)修饰符将应用于其包装的视图中所有可动画的更改。

HikeView.swift

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 SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤4

将动画类型从easeInOut()更改为spring()

SwiftUI包括预定义或自定义宽松的基本动画,以及弹簧和流体动画。可以调整动画的速度、在动画开始之前设置延迟或指定动画重复。

HikeView.swift

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 SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤5

尝试通过在scaleEffect修改器的正上方添加另一个动画修改器来关闭旋转的动画。

实验

围绕SwiftUI尝试组合不同的动画效果,看看有什么不同的效果。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤6

在转到下一节之前,请删除两个动画(:)修改器。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))

.scaleEffect(showDetail ? 1.5 : 1)
.padding()

}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第二节 使状态的改变产生动画效果

既然您已经学习了如何将动画应用于各个视图,现在是时候在更改状态值的位置添加动画了。

在这里,您将对用户点击按钮并切换showDetail属性时发生的所有更改添加动画。

步骤1

withAnimation函数包装showDetail.toggle()的调用。

showDetail属性影响的两个视图(disclosure按钮和HikeDetail视图)现在都具有动画过渡。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

减慢动画的速度,以查看SwiftUI动画是如何中断的。

步骤2

将4秒长的基本动画传递给withAnimation函数。

可以传递相同类型的动画给给animation(:)修饰符的withAnimation函数。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation(.easeInOut(duration: 4)) {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

尝试在动画中打开和关闭图形视图。

步骤4

在继续下一节之前,请从withAnimation函数调用中删除慢速动画。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第三节 自定义视图转场

默认情况下,视图通过淡入淡出在屏幕内外切换。您可以使用transition(:)修饰符自定义此转场。

步骤1

向条件可见的HikeView添加一个transition(_:)修饰符。

现在,图形通过滑动进入和退出视线而出现和消失。

HikeView.swift

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
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.slide)
}
}
}
}

步骤2

将转场提取为AnyTransition的静态属性。

这将在展开自定义转场时保持代码的干净。对于自定义转换,您可以使用与SwiftUI相同的.语法。

HikeView.swift

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
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.slide
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤3

切换到使用move(edge:) 转场,以便图形从同一侧滑入和滑出。

HikeView.swift

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
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.move(edge: .trailing)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤4

使用asymmetric(insertion:removal:)(不对称)修饰符在视图出现和消失时提供不同的过渡。

HikeView.swift

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
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

第四节 为复杂效果撰写动画

单击栏下方的按钮时,图形将在三组不同的数据之间切换。在本节中,将使用组合动画为构成图形的胶囊提供动态、波动的转场动画。

步骤1

showDetail的默认值更改为true,并将HikeView预览锁定到画布。

这使您在另一个文件中处理动画时依然可以在上下文中看到图表。

步骤2

GraphCapsule.swift中,添加新的计算动画属性,并将其应用于胶囊形状。

GraphCapsule.swift

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
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.default
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤3

将动画切换为弹簧动画spring,减少阻尼部分dampingFraction以使条形可以跳跃。

GraphCapsule.swift

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
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤4

稍微加快动画的速度,以缩短每个条形移动到新位置所需的时间。

GraphCapsule.swift

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
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤5

根据胶囊在图形上的位置为每个动画添加延迟delay

GraphCapsule.swift

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
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤6

观察自定义动画在图形之间转换时如何提供波动效果。

构建列表和导航

设置了基本地标详情视图后,需要为用户提供查看地标所有列表和查看每个地标详情的方法。

您将创建显示所有地标信息的视图,并动态生成一个滚动列表,用户可以点击该列表查看地标的详情视图。要微调UI,您将使用Xcode的画布以不同的设备大小呈现多个预览。

下载项目文件以开始构建此项目,并执行以下步骤。

学习时间:35分钟

下载示例:BuildingListsAndNavigation.zip

第一节 了解示例数据。

在第一个教程中,我们将数据硬编码到所有自定义视图中。在这里,您将学习如何将数据传递到自定义视图中以动态显示。

首先下载示例项目并熟悉示例数据。

步骤1

在项目导航器中,选择Models>Landmark.swift

Landmark.swift声明了一个Landmark结构,该结构存储应用程序需要显示的所有Landmark信息,并从landmarkData.json导入一组Landmark数据。

Landmark.swift

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
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤2

在项目导航器中,选择Resources>landmarkData.json

您将在本教程的其余部分以及随后的所有内容中使用此示例数据。

landmarkData.json

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
[
{
"name": "Turtle Rock",
"category": "Featured",
"city": "Twentynine Palms",
"state": "California",
"id": 1001,
"park": "Joshua Tree National Park",
"coordinates": {
"longitude": -116.166868,
"latitude": 34.011286
},
"imageName": "turtlerock"
},
{
"name": "Silver Salmon Creek",
"category": "Lakes",
"city": "Port Alsworth",
"state": "Alaska",
"id": 1002,
"park": "Lake Clark National Park and Preserve",
"coordinates": {
"longitude": -152.665167,
"latitude": 59.980167
},
"imageName": "silversalmoncreek"
},
...
]

步骤3

请注意,我们将ContentView类型重命名为LandmarkDetail

在本教程和以下每个教程中,您将创建多个视图类型。

LandmarkDetail.swift

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
import SwiftUI

struct LandmarkDetail: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

第二节 创建行视图

本节中构建的第一个视图是行视图,用于显示每个地标的详细信息。此行视图声明了一个属性即landmark ,其用于存储地标信息,以便行视图可以显示任何地标。稍后,您将把多个行视图合并成一个地标列表。

步骤1

创建一个新的SwiftUI视图,名为LandmarkRow.swift

步骤2

如果预览尚未显示,请通过选择Editor > Editor and Canvas,来显示画布,然后单击“Resume”。

步骤3

添加landmark作为LandmarkRow的存储属性。

添加landmark属性时,预览将停止工作,因为LandmarkRow类型在初始化期间需要landmark实例。

LandmarkRow.swift

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

struct LandmarkRow: View {
var landmark: Landmark

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

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow()
}
}

想要修复预览,需要修改PreviewProvider

步骤4

LandmarkRow_Previewsstatic previews属性中,将landmarkData数组的第一个元素作为LandmarkRow参数添加到LandmarkRow初始值设定项中。

预览可以正常显示文本Hello World了。

LandmarkRow.swift

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

struct LandmarkRow: View {
var landmark: Landmark

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

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

修复后,可以为行视图生成布局。

步骤5

将文本视图嵌入到HStack中。

LandmarkRow.swift

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

struct LandmarkRow: View {
var landmark: Landmark

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

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤6

修改文本视图以使用landmarkname属性。

LandmarkRow.swift

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

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤7

Text视图之前添加Image视图来完成行视图。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

第三节 自定义行预览

Xcode的画布自动识别并显示当前编辑器中遵守PreviewProvider协议的任何类型。PreviewProvider返回一个或多个视图,并提供配置大小和设备的选项。

您可以自定义PreviewProvider返回的内容,以准确呈现对您最有帮助的预览。

步骤1

LandmarkRow_Previews中,将landmark参数更新为landmarkData数组中的第二个元素。

预览立即显示第二个示例地标,而不是第一个。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
}
}

步骤2

使用previewLayout(_:) 设置一个与列表中的行近似的大小。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}

可以使用GroupPreviewProvider返回多个视图预览。

步骤3

将返回的行包装在一个Group中,然后再次添加第一行。

Group是用于对视图内容进行分组的容器。Xcode将组的子视图呈现为画布中的单独预览。

LandmarkRow.swift

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
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
.previewLayout(.fixed(width: 300, height: 70))
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
}

步骤4

要简化代码,请将previewLayout(:)调用移到Group的外部。

视图的子级视图继承视图的上下文设置,如预览配置。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

Tips:在PreviewProvider中编写的代码只会改变Xcode在画布中显示的内容。

第四节 创建地标列表

使用SwiftUI的List类型时,可以显示特定于平台的视图列表。列表的元素可以是静态的,比如您目前创建的堆栈的子视图,也可以是动态生成的。您甚至可以混合静态和动态生成的视图。

步骤1

创建一个新的SwiftUI View,名为LandmarkList.swift

步骤2

将默认Text视图替换为List,并提供前两个Landmark作为列表子级的LandmarkRow实例。

预览显示了两个地标,它们以适合iOS的列表样式呈现。

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
List {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第五节 动态化列表

您可以直接从集合中生成行,而不是单独的指定列表中的元素。

通过传递数据集合并为集合中的每个元素提供视图的闭包,可以创建显示集合元素的列表。列表使用提供的闭包将集合中的每个元素转换为子视图。

步骤1

删除两个静态LandmarkRow,并将landmarkData 传递给Lisst初始值设定项。

List使用identifiable数据。您可以通过以下两种方式之一生产identifiable数据:1、使用key path属性标识每个元素;2、使数据遵守Identifiable协议。

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in

}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

通过从闭包返回LandmarkRow来动态生成列表。

这将为landmarkData数组中的每个元素创建一个LandmarkRow

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

接下来,您将通过使Landmark遵守Identifiable来简化代码。

步骤3

切换到Landmark.swift并声明Identifiable协议。

由于Landmark类型已经具有Identifiable所需的id属性,因此没有其他工作要做。

Landmark.swift

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
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤4

切换回LandmarkList.swift并删除id参数。

从现在起,您将能够直接使用Landmark的集合。

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第六节 在列表和详情之间设置导航

列表呈现正确,但您还不能轻触每个LandmarkRow来查看该地标的详情页面。

通过把List嵌入到NavigationView中使其具有导航功能,然后在NavigationLink中嵌入每一行LandmarkRow,并设置要跳转的目标视图。

步骤1

NavigationView中嵌入动态生成的LandmarkList

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

在显示列表时,调用navigationBarTitle(:)修饰符方法设置导航栏的标题。

LandmarkList.swift

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

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

List的闭包中,将返回的Row包装在NavigationLink 中,指定LandmarkDetail视图作为跳转目标。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤4

通过切换到实时预览模式,您可以在预览中直接尝试导航功能。单击Live Preview按钮并点击地标行访问详情页。

第7节 将数据传递到子视图

LandmarkDetail视图仍然使用硬编码方式来显示地标。与LandmarkRow一样,LandmarkDetail类型及其包含的视图需要使用landmark属性作为其数据源。

从子视图开始,您将转换CircleImageMapViewLandmarkDetail以显示传入的数据,而不是对每行进行硬编码。

步骤1

CircleImage.swift中,将存储属性image添加到CircleImage

这是使用SwiftUI构建视图时的常见模式。您的自定义视图通常会包装一系列修饰符。

CircleImage.swift

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

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤2

更新CircleImage_Preview 以传递名为Turtle RockImage

CircleImage.swift

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

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}

步骤3

MapView.swift中,向MapView添加一个coordinate属性,并将代码转换为使用该属性,而不是硬编码纬度和经度。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {

let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤4

更新MapView_Preview以传递数据数组中第一个地标元素的坐标。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}

步骤5

LandmarkDetail.swift中,将Landmark属性添加到LandmarkDetail类中。

LandmarkDetail.swift

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 SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail()
}
}

步骤6

更新LandmarkDetail_Preview以使用landmarkData数组中的第一个地标元素。

LandmarkDetail.swift

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 SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤7

将所需的数据下传给自定义类型。

LandmarkDetail.swift

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 SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤8

最后,调用navigationBarTitle(_:displayMode:)修饰符,在显示详情视图时为导航栏提供标题。

LandmarkDetail.swift

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
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤9

SceneDelegate.swift中,将应用程序的根视图切换为LandmarkList

当在模拟器中独立运行(不是预览模式)时,您的应用程序将从SceneDelegate中定义的根视图开始。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: LandmarkList())
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤10

LandmarkList.swift中,将当前Landmark传递到目标详情页。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤11

切换到实时预览以查看从列表导航到详情视图时,是否显示正确的地标。

第8节 动态生成预览

接下来,将向LandmarkList_Previews添加代码,以呈现不同设备大小的列表视图预览。默认情况下,预览以激活状态设备的大小呈现。可以通过调用previewDevice(:)修饰符方法来更改预览设备。

步骤1

首先,将当前列表预览更改为iPhone SE大小的渲染器。

您可以提供Xcodescheme菜单中显示的任何设备的名称。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.previewDevice(PreviewDevice(rawValue: "iPhone SE"))
}
}

步骤2

LandmarkList_Previews中,使用设备名称数组作为数据,将LandmarkList嵌入ForEach实例中。

ForEach对集合的操作方式与list相同,这意味着您可以在任何可以使用子视图的地方使用它,例如在stackslistgroup等中。当数据元素是简单的值类型(如您在这里使用的字符串)时,可以使用.self作为identifierkey path

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
}
}
}

步骤3

使用previewDisplayName(_:)修饰符将设备名称添加为预览的标签。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}

步骤4

您可以尝试使用不同的设备来比较视图的渲染,所有这些都来自画布。

框架集成

创建watchOS应用程序

本教程为您提供了一个机会,让您可以应用您已经了解的有关SwiftUI的大部分内容,并且只需很少的努力将Landmarks应用程序迁移到watchOS

在复制为iOS应用程序创建的共享数据和视图之前,您将首先向项目中添加watchOS target。所有资源就绪后,您将自定义SwiftUI视图,以便在watchOS上显示详情视图和列表视图。

学习时间:25分钟

下载地址:CreatingAwatchOSApp.zip

第一节 添加watchOS Target

要创建watchOS应用程序,请首先将watchOS Target添加到项目中。Xcode将watchOS应用程序的文件夹和文件,以及构建和运行该应用程序所需的方案添加到项目中。

步骤1

选择File > New > Target。当工作模板表出现时,选择watchOS选项卡,选择Watch App for iOS应用程序模板,然后单击Next

此模板将新的watchOS应用程序添加到您的项目中,并与iOS应用程序配对。

步骤2

在表单中,输入WatchLandmarks作为产品名称。将语言设置为Swift,将用户界面设置为SwiftUI。选中Include Notification Scene复选框,然后单击“Finish”。

步骤3

如果Xcode提示,请单击“Activate”。

这将选择WatchLandmarks方案,以便您可以构建和运行watchOS应用程序。

步骤4

WatchLandmarks扩展的“General”选项卡中,选中Supports Running Without iOS App Installation(支持在不安装iOS应用程序的情况下运行)复选框。

尽可能创建一个独立的watchOS应用程序。独立的watchOS应用不需要iOS配套应用。

第二节 在目标之间共享文件

设置了watchOS目标后,需要共享iOS目标中的一些资源。您将重用Landmark应用程序的数据模型、一些资源文件以及两个平台都可以显示而无需修改的任何视图。

步骤1

在项目导航器中,单击命令以选择以下文件:LandmarkRow.swift、Landmark.swift、UserData.swift、Data.swift、Profile.swift、Hike.swift和CircleImage.swift。

Landmark.swift、UserData.swift、Data.swift、Profile.swift和Hike.swift定义了应用程序的数据模型。您不会使用模型的所有方面,但需要所有文件才能成功编译应用程序。LandmarkRow.swift和CircleImage.swift都是应用程序可以在watchOS上显示的视图,无需任何更改。

步骤2

在“文件检查器”中,选中“Target Membership”部分中的“WatchLandmarks Extension”复选框。

这使您在上一步中选择的文件可用于watchOS应用程序。

步骤3

在项目导航器中,选择Landmark组中的Assets.xclassets文件,并将其添加到File检查器的Target Membership部分中的WatchLandmarks Target中。

这与您在上一步中选择的Target不同。WatchLandmarks Extension Target包含你的应用程序的代码,而WatchLandmarks Target管理脚本、图标和相关资源。

步骤4

在项目导航器中,选择Resources文件夹中的所有文件,然后在File检查器的Target Membership中将它们添加到WatchLandmarks Extension target中。

第三节 创建详情视图

现在iOS目标资源已经准备好用于watch应用程序,您需要创建一个watch的视图来显示地标详情。为了测试详情视图,您将为最大和最小的手表尺寸创建自定义预览,并对圆形视图进行一些更改,以便所有内容都适合手表界面。

步骤1

在项目导航器中,单击WatchLandmarks Extension文件夹旁边的三角形以显示其内容,然后添加名为WatchLandmarkDetail的新SwiftUI视图。

步骤2

userData、landmark、landmarkIndex属性添加到WatchLandmarkDetail结构中。

这些属性与您在Handling User Input时添加到LandmarkDetail结构中的属性相同。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

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

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
WatchLandmarkDetail()
}
}

在上一步中添加属性后,Xcode中将出现参数丢失错误。要修复错误,您需要执行以下两项操作之一:为属性提供默认值,或传递参数设置视图的属性。

步骤3

在预览中,创建用户数据的实例,并使用它将landmark对象传递给WatchLandmarkView结构的初始值。还需要将用户数据设置为视图的环境对象。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

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

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤4

WatchLandmarkDetail.swift中,从body()方法返回CircleImage视图。

在这里,您可以重用iOS项目中的CircleImage视图。因为您创建了一个可调整大小的图像,所以调用.scaledToFill()将调整圆的大小,使其自动适配显示。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤5

为最大(44毫米)和最小(38毫米)的表盘创建预览。

通过对最大和最小的手表表面进行测试,你可以看到你的应用程序在屏幕上的缩放效果。一如既往,您应该在所有支持的设备大小上测试用户界面。

WatchLandmarkDetail.swift

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
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

圆形图像将调整大小以适配显示的高度。不幸的是,这也限制了圆的宽度。要解决裁减问题,您需要将图像嵌入VStack中,并进行一些额外的布局更改,以便圆形图像适合任何手表的宽度。

步骤6

将圆图像嵌入到VStack中。在图像下方显示地标名称及其信息。

如您所见,该信息不太适合在表盘屏幕上显示,但您可以通过将VStack放在滚动视图中来解决这个问题。

WatchLandmarkDetail.swift

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
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤7

在滚动视图中包装VStack

这会打开视图滚动,但会产生另一个问题:圆形图像现在会扩展到原大小,并且会调整其他UI元素的大小以匹配图像大小。您需要调整圆图像的大小,以便屏幕上只显示圆和地标名称。

WatchLandmarkDetail.swift

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
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤8

scaleToFill()更改为scaleToFit()

这将缩放圆图像以匹配显示器的宽度。

WatchLandmarkDetail.swift

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
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤9

添加padding,使地标名称在圆图像下方可见。

WatchLandmarkDetail.swift

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
51
52
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤10

后退按钮上添加标题。

这会将后退按钮的文本设置为“地标”。但是,在添加“地标列表”视图之前,您不会看到“后退”按钮。

WatchLandmarkDetail.swift

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
51
52
53
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第四节 添加watchOS地图视图

现在您已经创建了基本详情视图,现在是时候添加一个地图来显示地标的位置了。与CircleImage不同,你不能重用iOS应用程序的MapView。相反,您需要创建一个WKInterfaceObjectRepresentable结构来包装WatchKit的地图视图。

步骤1

WatchKit extension添加名为WatchMapView的自定义视图。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

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

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

步骤2

WatchMapView结构中,将View更改为WKInterfaceObjectRepresentable

要查看区别,请在步骤1和2所示的代码之间来回滚动。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var body: some View {
Text("Hello World!")
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

Xcode显示编译错误,因为WatchMapView尚未实现WKInterfaceObjectRepresentable协议属性。

步骤3

删除body()方法并将其替换为landmark属性。

无论何时创建地图视图,都需要传递此属性的值。例如,可以将landmark的实例传递给预览。

WatchMapView.swift

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

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤4

实现WKInterfaceObjectRepresentablemakeWKInterfaceObject(context:) 方法。

此方法用来创建WatchMapView显示的WatchKit地图。

WatchMapView.swift

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

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤5

通过实现WKInterfaceObjectRepresentableupdateWKInterfaceObject(_:,context:)方法,根据地标的坐标设置地图的区域。

现在,编译成功了。

WatchMapView.swift

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
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}

func updateWKInterfaceObject(_ map: WKInterfaceMap, context: WKInterfaceObjectRepresentableContext<WatchMapView>) {

let span = MKCoordinateSpan(latitudeDelta: 0.02,
longitudeDelta: 0.02)

let region = MKCoordinateRegion(
center: landmark.locationCoordinate,
span: span)

map.setRegion(region)
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤6

选择WatchLandmarkView.swift文件并将地图视图添加到VStack的底部。

代码添加了一个分隔符,后跟地图视图。.scaledToFit().padding()修饰符将地图以合适的大小适配屏幕。

WatchLandmarkDetail.swift

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
51
52
53
54
55
56
57
58
59
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)

Divider()

WatchMapView(landmark: self.landmark)
.scaledToFit()
.padding()
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第五节 创建跨平台列表视图

对于地标列表,您可以重用iOS应用程序中的行Row,但每个平台都需要呈现自己的详情视图。为了支持这一点,您将把LandmarkList视图转换为泛型列表类型,在这里实例化代码定义了详情视图。

步骤1 在工具栏中,选择Landmarks scheme

Xcode现在编译并运行应用程序的iOS版本。在将列表移动到watchOS应用程序之前,您要确保对LandmarkList视图的任何修改在iOS应用程序中仍然有效。

步骤2

选择LandmarkList.swift并更改类型声明,使其成为泛型类型。

LandmarksList.swift

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
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

在创建LandmarkList结构的实例时,添加泛型声明会导致Generic parameter could not be inferred(无法推断泛型参数)错误。以下步骤会修复这些错误。

步骤3

为创建局部视图的闭包添加属性。

LandmarksList.swift

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
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

步骤4

使用detailViewProducer属性为地标创建详情视图。

LandmarksList.swift

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
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

创建LandmarkList实例时,还需要提供一个闭包,用于创建landmark的详情视图。

步骤5

选择Home.swift。在CategoryHome结构的body()方法中,添加闭包以创建LandmarkDetail视图。

Xcode根据闭包的返回值推断LandmarkList结构的泛型类型。

Home.swift

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: CGFloat(200))
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList { LandmarkDetail(landmark: $0) }) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

// swiftlint:disable type_name
struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
.environmentObject(UserData())
}
}

步骤6

在LandmarkList.swift中,向预览添加类似的代码。

在这里,您需要使用条件判断,根据Xcode编译的当前scheme定义详情视图。现在地标应用程序可以按照预期在iOS上构建和运行了。

LandmarksList.swift

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 SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

第六节 添加地标列表

现在您已经更新了LandmarksList视图,以便它在两个平台上都能工作,您可以将其添加到watchOS应用程序中。

步骤1

在文件检查器中,将LandmarksList.swift添加到WatchLandmarks ExtensionTarget。

现在可以在watchOS应用程序的代码中使用LandmarkList视图。

步骤2

在工具栏中,将scheme更改为WatchLandmarks

步骤3

打开LandmarkList.swift,继续预览。

预览现在显示watchOS列表视图。

LandmarksList.swift

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 SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

watchOS应用程序的根视图是ContentView,它显示默认文本Hello World!

步骤4

修改ContentView,使其显示列表视图。

ContentView.swift

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

struct ContentView: View {
var body: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

步骤5

在模拟器中编译运行watchOS应用程序。

通过滚动列表中的地标来测试watchOS应用程序的行为,点击以查看地标的详细信息,并将其标记为收藏夹。单击back按钮返回列表,然后打开Favorite开关,这样您只能看到收藏的地标。

第7节 创建自定义通知界面

你的watchOS版地标应用程序几乎完成了。在这最后一部分中,您将创建一个通知界面,每当您离最喜欢的某一位置很近时,会收到地标信息的通知信息。

注意:本节仅介绍如何在收到通知后显示通知。它不描述如何设置或发送通知。

步骤1

打开NotificationView.swift并创建一个显示有关地标、标题和消息的信息的视图。

因为任何通知值都可以为nil,所以预览将显示通知视图的两个版本。第一个仅在未提供数据时显示默认值,第二个显示您提供的标题、消息和位置。

NotificationView.swift

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
import SwiftUI

struct NotificationView: View {

let title: String?
let message: String?
let landmark: Landmark?

init(title: String? = nil,
message: String? = nil,
landmark: Landmark? = nil) {
self.title = title
self.message = message
self.landmark = landmark
}

var body: some View {
VStack {

if landmark != nil {
CircleImage(image: landmark!.image.resizable())
.scaledToFit()
}

Text(title ?? "Unknown Landmark")
.font(.headline)
.lineLimit(0)

Divider()

Text(message ?? "You are within 5 miles of one of your favorite landmarks.")
.font(.caption)
.lineLimit(0)
}
}
}

struct NotificationView_Previews: PreviewProvider {

static var previews: some View {
Group {
NotificationView()

NotificationView(title: "Turtle Rock",
message: "You are within 5 miles of Turtle Rock.",
landmark: UserData().landmarks[0])
}
.previewLayout(.sizeThatFits)
}
}

步骤2

打开NotificationController并添加landmarktitlemessage属性。

这些数据存储了有关通知传入信息。

NotificationController.swift

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
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView()
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤3

更新body()方法以使用这些属性。

此方法实例化您先前创建的通知视图。

NotificationController.swift

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
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤4

定义LandmarkIndexKey

使用此键可从通知中提取地标索引。

NotificationController.swift

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
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤5

更新didReceive(:)方法以解析通知中的数据。

此方法更新控制器的属性。调用此方法后,系统将使控制器的body属性无效,该属性将更新导航视图。然后系统在Apple Watch上显示通知。

NotificationController.swift

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
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
let userData = UserData()

let notificationData =
notification.request.content.userInfo as? [String: Any]

let aps = notificationData?["aps"] as? [String: Any]
let alert = aps?["alert"] as? [String: Any]

title = alert?["title"] as? String
message = alert?["body"] as? String

if let index = notificationData?[landmarkIndexKey] as? Int {
landmark = userData.landmarks[index]
}
}
}

当Apple Watch收到通知时,它会创建与通知类别关联的通知控制器。若要设置通知控制器的类别,必须打开并编辑应用程序的情节提要。

步骤6

在项目导航栏中,选择“Watch Landmarks”文件夹并打开界面storyboard。选择指向静态通知界面控制器的箭头。

步骤7

在属性检查器中,将Notification Category的名称设置为LandmarkNear

配置测试负载来使用LandmarkNear类别,并传递通知控制器期望的数据。

步骤8

选择PushNotificationPayload.apns文件,并更新title、body、category和landmarkIndex属性。请务必将类别设置为LandmarkNear。您还可以删除本教程中未使用的任何键,如subtitleWatchKit Simulator ActionscustomKey

PushNotificationPayload.apns

1
2
3
4
5
6
7
8
9
10
11
12
{
"aps": {
"alert": {
"body": "You are within 5 miles of Silver Salmon Creek."
"title": "Silver Salmon Creek",
},
"category": "LandmarkNear",
"thread-id": "5280"
},

"landmarkIndex": 1
}

负载文件模拟远程通知中从服务器发送的数据。

步骤9

选择“Landmarks-Watch (Notification)” scheme,并编译和运行您的应用程序。

第一次运行通知Scheme时,系统将请求发送通知的权限。选择Allow(允许)。模拟器随后显示一个可滚动的通知,其中包括:一个用于将地标应用程序标记为发送者的框、通知视图和通知操作的按钮。

应用程序设计和布局

使用UI控件

在地标应用程序中,用户可以创建一个个人资料页来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式,并设计一个偏好设置页面。

您将使用各种用于数据输入的通用用户界面控件,并在用户保存更改时更新地标数据模型。

学习时间:25分钟

下载地址:WorkingWithUIControls.zip

第一节 显示用户简介

Landmarks应用程序在本地存储一些详情配置和偏好设置。在用户编辑其详情之前,它们将显示在没有任何编辑控件的摘要视图中。

步骤1

要开始,请在Landmarks目录下创建一个名为Profile的新目录,然后将名为ProfileHost的视图添加到该目录中。

ProfileHost视图将同时承载用户信息的静态摘要视图和编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
Text("Profile for: \(draftProfile.username)")
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

Home.swift中的静态文本替换为上一步中创建的ProfileHost

现在,主屏幕上的profile按钮将以模态方式展现用户简介。

Home.swift

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false
@EnvironmentObject var userData: UserData

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
.environmentObject(self.userData)
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

创建一个名为ProfileSummary的新视图,该视图接受一个Profile实例并显示一些基本的用户信息。

ProfileSummary持有一个Profile,比个人简介持有它好,因为父视图ProfileHost管理此视图的State

ProfileSummary.swift

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
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤4

更新ProfileHost以显示新的摘要视图。

ProfileHost.swift

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

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

创建一个名为HikeBadge 的新视图,该视图由绘制路径和形状中制作的徽章以及徒步旅行的一些数据文本组成。

徽章只是一个图形,因此HikeBadge中的文本和accessibility(label:) 修饰符使徽章的含义对其他用户更清晰。

注意:

两次调用frame(width:height:)修饰符,使徽章以其设计时的尺寸300×300点进行缩放渲染。

HikeBadge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct HikeBadge: View {
var name: String
var body: some View {
VStack(alignment: .center) {
Badge()
.frame(width: 300, height: 300)
.scaleEffect(1.0 / 3.0)
.frame(width: 100, height: 100)
Text(name)
.font(.caption)
.accessibility(label: Text("Badge for \(name)."))
}
}
}

struct HikeBadge_Previews: PreviewProvider {
static var previews: some View {
HikeBadge(name: "Preview Testing")
}
}

步骤6

更新ProfileSummary以添加不同颜色的徽章以及获得徽章的原因文字。

ProfileSummary.swift

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
51
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤7

通过引入视图动画与转场HikeView来完成ProfileSummary

ProfileSummary.swift

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
51
52
53
54
55
56
57
58
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}

VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)

HikeView(hike: hikeData[0])
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

第二节 添加编辑模式

用户需要在查看或编辑其简介详情之间切换。您将添加一个编辑模式,通过向现有的ProfileHost添加一个EditButton,然后创建一个带有控件的视图,用于编辑单个数据。

步骤1

添加一个Environment属性,并设置\.editMode

可以使用此属性读取和写入当前编辑范围。

ProfileHost.swift

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

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

创建一个编辑按钮,用于打开和关闭环境的编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

更新UserData类以包含用户简介的实例,该实例在用户关闭简介视图后仍然存在。

UserData.swift

1
2
3
4
5
6
7
8
import Combine
import SwiftUI

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
@Published var profile = Profile.default
}

步骤4

Environment中读取Profile数据,将数据的控制权传递给ProfileHost

为了避免在确认编辑之前更新全局应用程序状态(例如当用户输入其名称时),编辑视图将对其自身的副本进行操作。

ProfileHost.swift

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
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

添加条件视图,显示静态简介视图或编辑模式视图。

注意

目前,编辑模式只是一个静态文本字段。

ProfileHost.swift

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
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
Text("Profile Editor")
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

第三节 定义用户简介编辑器

用户简介编辑器主要由不同的控件组成,这些控件更改用户简介中的各个详情信息。配置文件中的某些项目(如徽章)不可由用户编辑,因此它们不会显示在编辑器中。

为了与信息摘要保持一致,您将在编辑器中按相同的顺序添加概要文件详情信息。

步骤1

创建一个名为ProfileEditor的新视图,并包含对用户简介副本的绑定。

视图中的第一个控件是一个TextField,它控制并更新一个字符串的绑定,是用户选择的显示名称。当创建TextField时,您需要提供标签和字符串的绑定。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤2

更新ProfileHost中的条件内容,使其包含Profile Editor,并传递简介信息的绑定。

现在,单击“Edit”时将显示“简介编辑视图”。

ProfileHost.swift

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
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

添加是否接收地标相关事件通知的开关。

Toggles是只有打开或关闭的控件,因此它们非常适合布尔值Boolean,如“yes”或“no”的设置。

ProfileEditor.swift

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
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤4

Picker控件及其标签放置在VStack中,使地标照片具有可选择的季节。

ProfileEditor.swift

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
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤5

最后,在季节选择器下面添加一个DatePicker,修改到达地标的日期。

ProfileEditor.swift

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
51
52
53
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var dateRange: ClosedRange<Date> {
let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
return min...max
}

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)

VStack(alignment: .leading, spacing: 20) {
Text("Goal Date").bold()
DatePicker(
"Goal Date",
selection: $profile.goalDate,
in: dateRange,
displayedComponents: .date)
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

第四节 延迟编辑传递

要使其编辑,直到用户退出编辑模式后才生效,在编辑过程中使用其Profile的草稿副本,然后仅当用户确认编辑时才将草稿副本分配给真实副本。

步骤1

ProfileHost添加取消按钮。

EditButton提供的Done按钮不同,Cancel按钮不会将编辑应用于其闭包中的真实Profile数据。

ProfileHost.swift

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
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

应用onAppear(perform:)onDisappear(perform:)修饰符,将正确的用户简介数据填充给编辑器,并在用户点击Done按钮时更新简介数据。

否则,在下次激活编辑模式时显示旧值。

ProfileHost.swift

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
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
.onAppear {
self.draftProfile = self.userData.profile
}
.onDisappear {
self.userData.profile = self.draftProfile
}
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

处理用户输入

Landmarks应用程序中,用户可以标记他们最喜欢的位置,并筛选列表来仅仅显示他们最喜欢的位置。要创建此功能,首先要向列表中添加一个开关,以便用户只关注他们的收藏夹,然后添加一个星形按钮,用户点击该按钮可将地标标记到收藏夹。

学习时间:20分钟

下载示例:HandlingUserInput.zip

第一节 标记用户最喜欢的地标

从优化列表开始,让用户一目了然地看到他们的最爱。为每个显示最喜欢的地标行添加一个星。

步骤1

打开Xcode项目,然后在项目导航器中选择LandmarkRow.swift

步骤2

Spacer()之后,在if语句中添加一个星星Image,用来测试当前地标是否被收藏。

SwiftUI语句块中,使用if语句有条件地包含视图。

LandmarkRow.swift

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
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

步骤3

由于系统图像是基于矢量的,因此可以使用foregroundColor(_:)修改器更改其颜色。

当地标的isFavorite属性为true时,星星就出现了。您将在本教程后面看到如何修改该属性。

LandmarkRow.swift

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
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
.foregroundColor(.yellow)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

第二节 筛选列表视图

您可以自定义列表视图,使其显示所有地标,或仅显示用户的收藏夹。为此,需要向LandmarkList类型添加@State

@State是一个值或一组值,可以随时间变化,并影响视图的行为、内容或布局。使用带有@State的属性将其添加到视图中。

步骤1

在项目导航器中选择LandmarkList.swift。将名为showFavoritesOnly@State属性添加到LandmarkList,其初始值设置为false

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

单击“Resume”按钮刷新画布。

当您对视图的结构进行更改(如添加或修改属性)时,需要手动刷新画布。

步骤3

通过检查showFavoritesOnly属性和每个landmark.isFavorite值筛选地标列表。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第三节 添加控件以切换状态

要让用户控制列表的筛选器,需要添加一个控件,该控件可以单独更改showFavoritesOnly的值。通过绑定toggle控件来完成此操作。

绑定是对可变状态的引用。当用户从关闭切换到打开,然后再次关闭时,控件使用绑定相应地更新视图的状态。

步骤1

将行嵌套到ForEach中。

若要在列表中组合静态视图和动态视图,或组合两个或多个不同的动态视图组,请使用ForEach类型,而不是将数据集合传递给列表。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

添加一个Toggle视图作为列表视图的第一个子视图,给showFavoritesOnly做一个绑定。

您可以使用$来访问状态变量或其绑定的属性。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

使用实时预览并通过点击切换来尝试此新功能。

第四节 使用Observable Object进行存储

为了让用户控制哪些特定的地标是最喜欢的,您首先要将地标数据存储在一个Observable Object中。

Observable Object的自定义对象,可以从SwiftUI环境中的存储绑定到视图。SwiftUI监视可观察对象的任何可能影响视图的更改,并在更改后显示正确的视图。

步骤1

创建一个名为UserData.Swift的新Swift文件。

UserData.swift

步骤2

从组合框架中声明遵守ObservableObject协议的新模型类型。

SwiftUI订阅您的ObservableObject,并在数据更改时更新任何需要刷新的视图。

UserData.swift

1
2
3
4
5
6
import SwiftUI
import Combine

final class UserData: ObservableObject {

}

步骤3

添加showFavoritesOnly和地标的存储属性及其初始值。

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
var showFavoritesOnly = false
var landmarks = landmarkData
}

ObservableObject需要发布对其数据的任何更改,以便其订阅者可以获取更改。

步骤4

@Published属性添加到模型中的每个属性

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
}

第五节 在视图中采用你的模型对象

现在您已经创建了UserData对象,您需要更新视图以将其作为应用程序的数据存储。

步骤1

LandmarkList.swift中,用@EnvironmentObject属性替换showFavoritesOnly声明,并向预览添加environmentObject(:)修饰符。

只要environmentObject(:)修饰符已应用于父对象,此userData属性就会自动获取其值。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤2

通过访问userData上的相同属性来替换showFavoritesOnly的使用。

@State属性一样,您可以使用$访问到userData对象成员的绑定。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤3

创建ForEach实例时使用userData.landmarks作为数据。

LandmarkList.swift

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
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤4

SceneDelegate.swift中,将environmentObject(:)修饰符添加到LandmarkList

如果您在模拟器或设备上构建并运行地标,而不是使用预览,则此更新将确保地标列表在环境中有一个UserData对象。

SceneDelegate.swift

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
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: LandmarkList()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤5

更新LandmarkDetail视图以在环境中使用UserData对象。

在访问或更新地标的收藏状态时,您将使用地标索引,以便始终访问该数据的正确版本。

LandmarkDetail.swift

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
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤6

切换回LandmarkList.swift并打开实时预览,以验证一切正常工作。

第六节 为每个地标创建收藏夹按钮

地标应用程序现在可以在过滤和未经过滤的地标视图之间切换,但最喜欢的地标列表仍然是硬编码的。要允许用户添加和删除收藏,需要将收藏按钮添加到地标详情视图。

步骤1

LandmarkDetail.swift中,将地标的名称嵌入HStack

LandmarkDetail.swift

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
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤2

在地标名称旁边创建一个新按钮。使用if-else条件语句提供不同的图像,以指示地标是否收藏。

在按钮的action闭包中,代码使用带有userData对象的landmarkIndex来更新地标。

LandmarkDetail.swift

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
51
52
53
54
55
56
57
58
59
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)

Button(action: {
self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
}) {
if self.userData.landmarks[self.landmarkIndex].isFavorite {
Image(systemName: "star.fill")
.foregroundColor(Color.yellow)
} else {
Image(systemName: "star")
.foregroundColor(Color.gray)
}
}
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤3

切换回LandmarkList.swift,并打开实时预览。

当您从列表导航到详情信息并点击按钮时,这些更改将在您返回列表时保持不变。因为两个视图都在访问环境中的同一个模型对象,所以两个视图保持一致性。

User Interface

视图和控件

在屏幕上显示内容并处理用户交互。

概述

视图和控件是应用程序用户界面的可视化构建区块。使用它们在屏幕上显示应用程序的内容。视图可以描述文本、图像、形状、自定义绘图以及所有这些内容的组合。控件允许用户使用一致的API与其相应的平台和上下文进行交互。

使用指定其视觉关系和层次结构的容器合并视图。使用名为修饰符modifiers的方法自定义内置视图和为应用程序创建的视图的显示、行为和交互。

将修饰符modifiers应用于视图和控件:

  • 控制视图的大小、位置和外观属性。
  • 响应轻触、手势和其他用户交互。
  • 支持拖拽操作。
  • 自定义动画和转场。
  • 设置样式首选项和其他环境数据。

有关如何使用视图和控件的其他信息,请参见人机界面指南。

话题

摘要

protocol View

视图:用来描述SwiftUI的视图类型。

Creating and Combining Views

创建并组合视图:本教程将指导您构建地标,这是一个iOS应用程序,用于发现和共享您喜欢的地方。您将首先构建显示地标的详情视图。

Working with UI Controls

使用UI控件:在地标应用程序中,用户可以创建个人简介来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式并设计首选项页面。

文本 Text

struct Text

文本:显示一行或多行只读文本的视图。

struct TextField

文本输入框:显示可编辑文本的控件。

struct SecureField

密文输入框:用户安全输入私密文本的控件。

struct Font

字体:依赖于环境的字体。

图像 Images

struct Image

图像:显示依赖于环境的图像视图。

按钮 Buttons

struct Button

按钮:触控时执行操作的控件。

struct NavigationLink

导航链接:按下时触发导航显示的按钮。

struct MenuButton

菜单按钮:当按下时显示包含选项列表的菜单的按钮。

struct EditButton

编辑按钮:切换当前编辑范围的编辑模式的按钮。

struct PasteButton

粘贴按钮:触发从粘贴板读取数据的系统按钮。

值选择器 Value Selectors

struct Toggle

开关:在打开和关闭状态之间切换的控件。

struct Picker

选择器:从一组互斥值中进行选择的控件。

struct DatePicker

日期选择器:用于选择绝对日期的控件。

struct Slider

滑块:从有界线性值范围中选择值的控件。

struct Stepper

步进器:用于执行递增和递减操作的控件。

支持类型 Supporting Types

struct ViewBuilder

视图构建器:从闭包构造视图的自定义参数属性。

protocol ViewModifier

视图修饰器:应用于视图或其他视图的修饰器,生成原始值的不同版本。

protocol

View

一个表示SwiftUI视图的类型。

代码释义

声明

protocol View

概述

通过声明遵守View protocol的类型来创建自定义视图。实现所需的body计算属性,以提供自定义视图的内容和行为。

主题

1 - Implementing a Custom View - 实现自定义视图

1.1 视图的内容和行为。必需的,默认实现。

demo

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
/// 1 - Implementing a Custom View - 实现自定义视图
/// 视图的内容和行为。
/// 必需的。默认实现。
var body: some View {
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
}
}

2 - Sizing a Views - 制定视图的大小

2.1 将视图定位在具有指定大小的不可见框架中

1
func frame(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> View

2.2 将视图定位在具有指定宽度和高度的不可见框架中。

1
func frame(minWidth: CGFloat?, idealWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, idealHeight: CGFloat?, maxHeight: CGFloat?, alignment: Alignment) -> View

2.3 将视图修复为理想大小。

2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。

1
func fixedSize(horizontal: Bool, vertical: Bool) -> View

2.5 设置父布局应将空间分配给子布局的优先级,默认为0。

1
func layoutPriority(Double) -> View

demo

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
struct SizingInViews: View {
/// 2 - Sizing a Views - 制定视图大小
var body: some View {
VStack {
/// 2.1 将视图定位在具有指定大小的不可见框架中。
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(width: 200, height: 50, alignment: .topLeading)

/// 2.2 将视图定位在具有指定宽度和高度的不可见框架中。
Text("Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(minWidth: 10, idealWidth: 60, maxWidth: 300, minHeight: 10, idealHeight: 60, maxHeight: 300, alignment: .topTrailing)

/// 2.3 将视图固定在其理想大小。
Text("Hello, World3")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize()

/// 2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。
Text("Hello, World4Hello, World4HelloHello, World4Hello, World4HelloHello")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 200, height: 200)

/// 2.5 指定布局优先级 默认为0
Text("Hello, World5")
.background(Color.yellow)
.border(Color.red, width: 2)
.layoutPriority(1)

}
}
}

3 - Positioning a View - 定位视图

3.1 将视图的中心固定在其父坐标空间的指定点上。

1
func position(CGPoint) -> View

3.2 将视图的中心固定在其父坐标空间中指定的坐标上。

1
func position(x: CGFloat, y: CGFloat) -> View

3.3 通过给定Size中的widthheight偏移视图。

1
func offset(CGSize) -> View

3.4 通过指定的xy值偏移视图。

1
func offset(x: CGFloat, y: CGFloat) -> View

3.5 将视图延展到指定边缘的安全区域之外。

1
func edgesIgnoringSafeArea(Edge.Set) -> View

3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。

1
func coordinateSpace<T>(name: T) -> View

demo

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
struct PositioningAViews: View {
/// 3 - Positioning a View - 定位视图
var body: some View {
ZStack {
/// 3.1 将视图的中心固定在其父坐标空间的指定点上。
Text("Hello, World!")
.background(Color.red)
.position(CGPoint(x: 100, y: 0))

/// 3.2 将视图的中心固定在其父坐标空间中指定的坐标上。
Text("Hello, World2")
.background(Color.green)
.position(x: 100, y: 30)

/// 3.3 通过给定`Size`中的`width`和`height`偏移视图。
Text("Hello, World3")
.background(Color.red)
.position(x: 100, y: 60)
.offset(CGSize(width: 30, height: 0))

/// 3.4 通过指定的`x`和`y`值偏移视图。
Text("Hello, World4")
.background(Color.green)
.position(x: 100, y: 90)
.offset(x: -30, y: 0)

}
.edgesIgnoringSafeArea(.top)/// 3.5 将视图延展到指定边缘的安全区域之外。
.coordinateSpace(name: "test")/// 3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。
}
}

4 - Aligning Views - 设置视图的对其方式

4.1 设置视图的水平对齐方式。

1
func alignmentGuide(HorizontalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.2 设置视图的垂直

对齐方式。

1
func alignmentGuide(VerticalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.3 视图在其自身的坐标空间中的大小和对齐方式的规则。

4.4 用于标识对齐规则的类型。

5 - Adjusting the Padding of a View - 调整视图边距

5.1 将视图沿所有边缘内嵌填充指定的大小。

1
func padding(CGFloat) -> View

5.2 使用指定的EdgeInsets内嵌填充视图。

1
func padding(EdgeInsets) -> View

5.3 使用指定的Edge集合内嵌填充指定大小。

1
func padding(Edge.Set, CGFloat?) -> View

5.4 定义了矩形各边的内嵌距离的结构体。

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct AdjustingPaddingView: View {
/// 5 - Adjusting the Padding of a View - 调整视图边距
var body: some View {
ZStack {

Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
.background(Color.yellow)
.border(Color.red, width: 2)
.position(x: 100, y: 30)
.padding()

}
.background(Color.gray)
// .padding()/// 默认边距
// .padding(30) /// 5.1 将视图沿所有边缘内嵌填充指定的大小。
// .padding(EdgeInsets(top: 20, leading: 10, bottom: 60, trailing: 50)) /// 5.2 使用指定的`EdgeInsets`内嵌填充视图。
.padding([.leading,.trailing], 20) /// 5.3 使用指定的`Edge`集合内嵌填充指定大小。

}
}

留言與分享

swift高级运算符

分類 编程语言, swift

高级运算符

除了之前介绍过的 基本运算符,Swift 还提供了数种可以对数值进行复杂运算的高级运算符。它们包含了在 C 和 Objective-C 中已经被大家所熟知的位运算符和移位运算符。

与 C 语言中的算术运算符不同,Swift 中的算术运算符默认是不会溢出的。所有溢出行为都会被捕获并报告为错误。如果想让系统允许溢出行为,可以选择使用 Swift 中另一套默认支持溢出的运算符,比如溢出加法运算符(&+)。所有的这些溢出运算符都是以 & 开头的。

自定义结构体、类和枚举时,如果也为它们提供标准 Swift 运算符的实现,将会非常有用。在 Swift 中为这些运算符提供自定义的实现非常简单,运算符也会针对不同类型使用对应实现。

我们不用被预定义的运算符所限制。在 Swift 中可以自由地定义中缀、前缀、后缀和赋值运算符,它们具有自定义的优先级与关联值。这些运算符在代码中可以像预定义的运算符一样使用,你甚至可以扩展已有的类型以支持自定义运算符。

位运算符

位运算符可以操作数据结构中每个独立的比特位。它们通常被用在底层开发中,比如图形编程和创建设备驱动。位运算符在处理外部资源的原始数据时也十分有用,比如对自定义通信协议传输的数据进行编码和解码。

Swift 支持 C 语言中的全部位运算符,接下来会一一介绍。

Bitwise NOT Operator(按位取反运算符)

*按位取反运算符(~)*对一个数值的全部比特位进行取反:

Art/bitwiseNOT_2x.png

按位取反运算符是一个前缀运算符,直接放在运算数之前,并且它们之间不能添加任何空格:

1
2
let initialBits: UInt8 = 0b00001111
let invertedBits = ~initialBits // 等于 0b11110000

UInt8 类型的整数有 8 个比特位,可以存储 0 ~ 255 之间的任意整数。这个例子初始化了一个 UInt8 类型的整数,并赋值为二进制的 00001111,它的前 4 位为 0,后 4 位为 1。这个值等价于十进制的 15

接着使用按位取反运算符创建了一个名为 invertedBits 的常量,这个常量的值与全部位取反后的 initialBits 相等。即所有的 0 都变成了 1,同时所有的 1 都变成 0invertedBits 的二进制值为 11110000,等价于无符号十进制数的 240

Bitwise AND Operator(按位与运算符)

按位与运算符(& 对两个数的比特位进行合并。它返回一个新的数,只有当两个数的对应位1 的时候,新数的对应位才为 1

Art/bitwiseAND_2x.png

在下面的示例当中,firstSixBitslastSixBits 中间 4 个位的值都为 1。使用按位与运算符之后,得到二进制数值 00111100,等价于无符号十进制数的 60

1
2
3
let firstSixBits: UInt8 = 0b11111100
let lastSixBits: UInt8 = 0b00111111
let middleFourBits = firstSixBits & lastSixBits // 等于 00111100

Bitwise OR Operator(按位或运算符)

按位或运算符(|可以对两个数的比特位进行比较。它返回一个新的数,只要两个数的对应位中有任意一个1 时,新数的对应位就为 1

Art/bitwiseOR_2x.png

在下面的示例中,someBitsmoreBits 存在不同的位被设置为 1。使用按位或运算符之后,得到二进制数值 11111110,等价于无符号十进制数的 254

1
2
3
let someBits: UInt8 = 0b10110010
let moreBits: UInt8 = 0b01011110
let combinedbits = someBits | moreBits // 等于 11111110

Bitwise XOR Operator(按位异或运算符)

按位异或运算符,或称“排外的或运算符”(^),可以对两个数的比特位进行比较。它返回一个新的数,当两个数的对应位不相同时,新数的对应位就为 1,并且对应位相同时则为 0

Art/bitwiseXOR_2x.png

在下面的示例当中,firstBitsotherBits 都有一个自己为 1,而对方为 0 的位。按位异或运算符将新数的这两个位都设置为 1。在其余的位上 firstBitsotherBits 是相同的,所以设置为 0

1
2
3
let firstBits: UInt8 = 0b00010100
let otherBits: UInt8 = 0b00000101
let outputBits = firstBits ^ otherBits // 等于 00010001

Bitwise Left and Right Shift Operators(按位左移、右移运算符)

按位左移运算符(<< 和 *按位右移运算符(>>)*可以对一个数的所有位进行指定位数的左移和右移,但是需要遵守下面定义的规则。

对一个数进行按位左移或按位右移,相当于对这个数进行乘以 2 或除以 2 的运算。将一个整数左移一位,等价于将这个数乘以 2,同样地,将一个整数右移一位,等价于将这个数除以 2。

无符号整数的移位运算

对无符号整数进行移位的规则如下:

  1. 已存在的位按指定的位数进行左移和右移。
  2. 任何因移动而超出整型存储范围的位都会被丢弃。
  3. 0 来填充移位后产生的空白位。

这种方法称为逻辑移位

以下这张图展示了 11111111 << 1(即把 11111111 向左移动 1 位),和 11111111 >> 1(即把 11111111 向右移动 1 位)的结果。蓝色的数字是被移位的,灰色的数字是被抛弃的,橙色的 0 则是被填充进来的:

Art/bitshiftUnsigned_2x.png

下面的代码演示了 Swift 中的移位运算:

1
2
3
4
5
6
let shiftBits: UInt8 = 4 // 即二进制的 00000100
shiftBits << 1 // 00001000
shiftBits << 2 // 00010000
shiftBits << 5 // 10000000
shiftBits << 6 // 00000000
shiftBits >> 2 // 00000001

可以使用移位运算对其他的数据类型进行编码和解码:

1
2
3
4
let pink: UInt32 = 0xCC6699
let redComponent = (pink & 0xFF0000) >> 16 // redComponent 是 0xCC,即 204
let greenComponent = (pink & 0x00FF00) >> 8 // greenComponent 是 0x66, 即 102
let blueComponent = pink & 0x0000FF // blueComponent 是 0x99,即 153

这个示例使用了一个命名为 pinkUInt32 型常量来存储 Cascading Style Sheets(CSS)中粉色的颜色值。该 CSS 的颜色值 #CC6699,在 Swift 中表示为十六进制的 0xCC6699。然后利用按位与运算符(&)和按位右移运算符(>>)从这个颜色值中分解出红(CC)、绿(66)以及蓝(99)三个部分。

红色部分是通过对 0xCC66990xFF0000 进行按位与运算后得到的。0xFF0000 中的 0 部分“掩盖”了 OxCC6699 中的第二、第三个字节,使得数值中的 6699 被忽略,只留下 0xCC0000

然后,将这个数向右移动 16 位(>> 16)。十六进制中每两个字符占用 8 个比特位,所以移动 16 位后 0xCC0000 就变为 0x0000CC。这个数和 0xCC 是等同的,也就是十进制数值的 204

同样的,绿色部分通过对 0xCC66990x00FF00 进行按位与运算得到 0x006600。然后将这个数向右移动 8 位,得到 0x66,也就是十进制数值的 102

最后,蓝色部分通过对 0xCC66990x0000FF 进行按位与运算得到 0x000099。这里不需要再向右移位,而 0x000099 也就是 0x99 ,也就是十进制数值的 153

有符号整数的移位运算

对比无符号整数,有符号整数的移位运算相对复杂得多,这种复杂性源于有符号整数的二进制表现形式。(为了简单起见,以下的示例都是基于 8 比特的有符号整数,但是其中的原理对任何位数的有符号整数都是通用的。)

有符号整数使用第 1 个比特位(通常被称为符号位)来表示这个数的正负。符号位为 0 代表正数,为 1 代表负数。

其余的比特位(通常被称为数值位)存储了实际的值。有符号正整数和无符号数的存储方式是一样的,都是从 0 开始算起。这是值为 4Int8 型整数的二进制位表现形式:

Art/bitshiftSignedFour_2x.png

符号位为 0(代表这是一个“正数”),另外 7 位则代表了十进制数值 4 的二进制表示。

负数的存储方式略有不同。它存储 2n 次方减去其实际值的绝对值,这里的 n 是数值位的位数。一个 8 比特位的数有 7 个比特位是数值位,所以是 27 次方,即 128

这是值为 -4Int8 型整数的二进制表现形式:

Art/bitshiftSignedMinusFour_2x.png

这次的符号位为 1,说明这是一个负数,另外 7 个位则代表了数值 124(即 128 - 4)的二进制表示:

Art/bitshiftSignedMinusFourValue_2x.png

负数的表示通常被称为二进制补码。用这种方法来表示负数乍看起来有点奇怪,但它有几个优点。

首先,如果想对 -1-4 进行加法运算,我们只需要对这两个数的全部 8 个比特位执行标准的二进制相加(包括符号位),并且将计算结果中超出 8 位的数值丢弃:

Art/bitshiftSignedAddition_2x.png

其次,使用二进制补码可以使负数的按位左移和右移运算得到跟正数同样的效果,即每向左移一位就将自身的数值乘以 2,每向右一位就将自身的数值除以 2。要达到此目的,对有符号整数的右移有一个额外的规则:当对有符号整数进行按位右移运算时,遵循与无符号整数相同的规则,但是对于移位产生的空白位使用符号位进行填充,而不是用 0

Art/bitshiftSigned_2x.png

这个行为可以确保有符号整数的符号位不会因为右移运算而改变,这通常被称为算术移位

由于正数和负数的特殊存储方式,在对它们进行右移的时候,会使它们越来越接近 0。在移位的过程中保持符号位不变,意味着负整数在接近 0 的过程中会一直保持为负。

溢出运算符

当向一个整数类型的常量或者变量赋予超过它容量的值时,Swift 默认会报错,而不是允许生成一个无效的数。这个行为为我们在运算过大或者过小的数时提供了额外的安全性。

例如,Int16 型整数能容纳的有符号整数范围是 -3276832767。当为一个 Int16 类型的变量或常量赋予的值超过这个范围时,系统就会报错:

1
2
3
4
var potentialOverflow = Int16.max
// potentialOverflow 的值是 32767,这是 Int16 能容纳的最大整数
potentialOverflow += 1
// 这里会报错

在赋值时为过大或者过小的情况提供错误处理,能让我们在处理边界值时更加灵活。

然而,当你希望的时候也可以选择让系统在数值溢出的时候采取截断处理,而非报错。Swift 提供的三个溢出运算符来让系统支持整数溢出运算。这些运算符都是以 & 开头的:

  • 溢出加法 &+
  • 溢出减法 &-
  • 溢出乘法 &*

数值溢出

数值有可能出现上溢或者下溢。

这个示例演示了当我们对一个无符号整数使用溢出加法(&+)进行上溢运算时会发生什么:

1
2
3
4
var unsignedOverflow = UInt8.max
// unsignedOverflow 等于 UInt8 所能容纳的最大整数 255
unsignedOverflow = unsignedOverflow &+ 1
// 此时 unsignedOverflow 等于 0

unsignedOverflow 被初始化为 UInt8 所能容纳的最大整数(255,以二进制表示即 11111111)。然后使用溢出加法运算符(&+)对其进行加 1 运算。这使得它的二进制表示正好超出 UInt8 所能容纳的位数,也就导致了数值的溢出,如下图所示。数值溢出后,仍然留在 UInt8 边界内的值是 00000000,也就是十进制数值的 0

Art/overflowAddition_2x.png

当允许对一个无符号整数进行下溢运算时也会产生类似的情况。这里有一个使用溢出减法运算符(&-)的例子:

1
2
3
4
var unsignedOverflow = UInt8.min
// unsignedOverflow 等于 UInt8 所能容纳的最小整数 0
unsignedOverflow = unsignedOverflow &- 1
// 此时 unsignedOverflow 等于 255

UInt8 型整数能容纳的最小值是 0,以二进制表示即 00000000。当使用溢出减法运算符对其进行减 1 运算时,数值会产生下溢并被截断为 11111111, 也就是十进制数值的 255

Art/overflowUnsignedSubtraction_2x.png

溢出也会发生在有符号整型上。针对有符号整型的所有溢出加法或者减法运算都是按位运算的方式执行的,符号位也需要参与计算,正如 按位左移、右移运算符 所描述的。

1
2
3
4
var signedOverflow = Int8.min
// signedOverflow 等于 Int8 所能容纳的最小整数 -128
signedOverflow = signedOverflow &- 1
// 此时 signedOverflow 等于 127

Int8 型整数能容纳的最小值是 -128,以二进制表示即 10000000。当使用溢出减法运算符对其进行减 1 运算时,符号位被翻转,得到二进制数值 01111111,也就是十进制数值的 127,这个值也是 Int8 型整所能容纳的最大值。

Art/overflowSignedSubtraction_2x.png

对于无符号与有符号整型数值来说,当出现上溢时,它们会从数值所能容纳的最大数变成最小数。同样地,当发生下溢时,它们会从所能容纳的最小数变成最大数。

优先级和结合性

运算符的优先级使得一些运算符优先于其他运算符;它们会先被执行。

结合性定义了相同优先级的运算符是如何结合的,也就是说,是与左边结合为一组,还是与右边结合为一组。可以将其理解为“它们是与左边的表达式结合的”,或者“它们是与右边的表达式结合的”。

当考虑一个复合表达式的计算顺序时,运算符的优先级和结合性是非常重要的。举例来说,运算符优先级解释了为什么下面这个表达式的运算结果会是 17

1
2
2 + 3 % 4 * 5
// 结果是 17

如果你直接从左到右进行运算,你可能认为运算的过程是这样的:

  • 2 + 3 = 5
  • 5 % 4 = 1
  • 1 * 5 = 5

但是正确答案是 17 而不是 5。优先级高的运算符要先于优先级低的运算符进行计算。与 C 语言类似,在 Swift 中,乘法运算符(*)与取余运算符(%)的优先级高于加法运算符(+)。因此,它们的计算顺序要先于加法运算。

而乘法运算与取余运算的优先级相同。这时为了得到正确的运算顺序,还需要考虑结合性。乘法运算与取余运算都是左结合的。可以将这考虑成,从它们的左边开始为这两部分表达式都隐式地加上括号:

1
2 + ((3 % 4) * 5)

(3 % 4) 等于 3,所以表达式相当于:

1
2 + (3 * 5)

3 * 5 等于 15,所以表达式相当于:

1
2 + 15

因此计算结果为 17

有关 Swift 标准库提供的操作符信息,包括操作符优先级组和结核性设置的完整列表,请参见 操作符声明

注意

相对 C 语言和 Objective-C 来说,Swift 的运算符优先级和结合性规则更加简洁和可预测。但是,这也意味着它们相较于 C 语言及其衍生语言并不是完全一致。在对现有的代码进行移植的时候,要注意确保运算符的行为仍然符合你的预期。

运算符函数

类和结构体可以为现有的运算符提供自定义的实现。这通常被称为运算符重载

下面的例子展示了如何让自定义的结构体支持加法运算符(+)。算术加法运算符是一个二元运算符,因为它是对两个值进行运算,同时它还可以称为中缀运算符,因为它出现在两个值中间。

例子中定义了一个名为 Vector2D 的结构体用来表示二维坐标向量 (x, y),紧接着定义了一个可以将两个 Vector2D 结构体实例进行相加的运算符函数

1
2
3
4
5
6
7
8
9
struct Vector2D {
var x = 0.0, y = 0.0
}

extension Vector2D {
static func + (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y + right.y)
}
}

该运算符函数被定义为 Vector2D 上的一个类方法,并且函数的名字与它要进行重载的 + 名字一致。因为加法运算并不是一个向量必需的功能,所以这个类方法被定义在 Vector2D 的一个扩展中,而不是 Vector2D 结构体声明内。而算术加法运算符是二元运算符,所以这个运算符函数接收两个类型为 Vector2D 的参数,同时有一个 Vector2D 类型的返回值。

在这个实现中,输入参数分别被命名为 leftright,代表在 + 运算符左边和右边的两个 Vector2D 实例。函数返回了一个新的 Vector2D 实例,这个实例的 xy 分别等于作为参数的两个实例的 xy 的值之和。

这个类方法可以在任意两个 Vector2D 实例中间作为中缀运算符来使用:

1
2
3
4
let vector = Vector2D(x: 3.0, y: 1.0)
let anotherVector = Vector2D(x: 2.0, y: 4.0)
let combinedVector = vector + anotherVector
// combinedVector 是一个新的 Vector2D 实例,值为 (5.0, 5.0)

这个例子实现两个向量 (3.0,1.0)(2.0,4.0) 的相加,并得到新的向量 (5.0,5.0)。这个过程如下图示:

Art/vectorAddition_2x.png

前缀和后缀运算符

上个例子演示了一个二元中缀运算符的自定义实现。类与结构体也能提供标准一元运算符的实现。一元运算符只运算一个值。当运算符出现在值之前时,它就是前缀的(例如 -a),而当它出现在值之后时,它就是后缀的(例如 b!)。

要实现前缀或者后缀运算符,需要在声明运算符函数的时候在 func 关键字之前指定 prefix 或者 postfix 修饰符:

1
2
3
4
5
extension Vector2D {
static prefix func - (vector: Vector2D) -> Vector2D {
return Vector2D(x: -vector.x, y: -vector.y)
}
}

这段代码为 Vector2D 类型实现了一元运算符(-a)。由于该运算符是前缀运算符,所以这个函数需要加上 prefix 修饰符。

对于简单数值,一元负号运算符可以对它们的正负性进行改变。对于 Vector2D 来说,该运算将其 xy 属性的正负性都进行了改变:

1
2
3
4
5
let positive = Vector2D(x: 3.0, y: 4.0)
let negative = -positive
// negative 是一个值为 (-3.0, -4.0) 的 Vector2D 实例
let alsoPositive = -negative
// alsoPositive 是一个值为 (3.0, 4.0) 的 Vector2D 实例

复合赋值运算符

复合赋值运算符将赋值运算符(=)与其它运算符进行结合。例如,将加法与赋值结合成加法赋值运算符(+=)。在实现的时候,需要把运算符的左参数设置成 inout 类型,因为这个参数的值会在运算符函数内直接被修改。

在下面的例子中,对 Vector2D 实例实现了一个加法赋值运算符函数:

1
2
3
4
5
extension Vector2D {
static func += (left: inout Vector2D, right: Vector2D) {
left = left + right
}
}

因为加法运算在之前已经定义过了,所以在这里无需重新定义。在这里可以直接利用现有的加法运算符函数,用它来对左值和右值进行相加,并再次赋值给左值:

1
2
3
4
var original = Vector2D(x: 1.0, y: 2.0)
let vectorToAdd = Vector2D(x: 3.0, y: 4.0)
original += vectorToAdd
// original 的值现在为 (4.0, 6.0)

注意

不能对默认的赋值运算符(=)进行重载。只有复合赋值运算符可以被重载。同样地,也无法对三元条件运算符 (a ? b : c) 进行重载。

等价运算符

通常情况下,自定义的类和结构体没有对等价运算符进行默认实现,等价运算符通常被称为相等运算符(==)与不等运算符(!=)。

为了使用等价运算符对自定义的类型进行判等运算,需要为“相等”运算符提供自定义实现,实现的方法与其它中缀运算符一样, 并且增加对标准库 Equatable 协议的遵循:

1
2
3
4
5
extension Vector2D: Equatable {
static func == (left: Vector2D, right: Vector2D) -> Bool {
return (left.x == right.x) && (left.y == right.y)
}
}

上述代码实现了“相等”运算符(==)来判断两个 Vector2D 实例是否相等。对于 Vector2D 来说,“相等”意味着“两个实例的 xy 都相等”,这也是代码中用来进行判等的逻辑。如果你已经实现了“相等”运算符,通常情况下你并不需要自己再去实现“不等”运算符(!=)。标准库对于“不等”运算符提供了默认的实现,它简单地将“相等”运算符的结果进行取反后返回。

现在我们可以使用这两个运算符来判断两个 Vector2D 实例是否相等:

1
2
3
4
5
6
let twoThree = Vector2D(x: 2.0, y: 3.0)
let anotherTwoThree = Vector2D(x: 2.0, y: 3.0)
if twoThree == anotherTwoThree {
print("These two vectors are equivalent.")
}
// 打印“These two vectors are equivalent.”

多数简单情况下,您可以使用 Swift 为您提供的等价运算符默认实现。Swift 为以下数种自定义类型提供等价运算符的默认实现:

  • 只拥有存储属性,并且它们全都遵循 Equatable 协议的结构体
  • 只拥有关联类型,并且它们全都遵循 Equatable 协议的枚举
  • 没有关联类型的枚举

在类型原始的声明中声明遵循 Equatable 来接收这些默认实现。

下面为三维位置向量 (x, y, z) 定义的 Vector3D 结构体,与 Vector2D 类似。由于 xyz 属性都是 Equatable 类型,Vector3D 获得了默认的等价运算符实现。

1
2
3
4
5
6
7
8
9
10
struct Vector3D: Equatable {
var x = 0.0, y = 0.0, z = 0.0
}

let twoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
let anotherTwoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
if twoThreeFour == anotherTwoThreeFour {
print("These two vectors are also equivalent.")
}
// 打印“These two vectors are also equivalent.”

自定义运算符

除了实现标准运算符,在 Swift 中还可以声明和实现自定义运算符。可以用来自定义运算符的字符列表请参考 运算符

新的运算符要使用 operator 关键字在全局作用域内进行定义,同时还要指定 prefixinfix 或者 postfix 修饰符:

1
prefix operator +++

上面的代码定义了一个新的名为 +++ 的前缀运算符。对于这个运算符,在 Swift 中并没有已知的意义,因此在针对 Vector2D 实例的特定上下文中,给予了它自定义的意义。对这个示例来讲,+++ 被实现为“前缀双自增”运算符。它使用了前面定义的复合加法运算符来让矩阵与自身进行相加,从而让 Vector2D 实例的 x 属性和 y 属性值翻倍。你可以像下面这样通过对 Vector2D 添加一个 +++ 类方法,来实现 +++ 运算符:

1
2
3
4
5
6
7
8
9
10
11
extension Vector2D {
static prefix func +++ (vector: inout Vector2D) -> Vector2D {
vector += vector
return vector
}
}

var toBeDoubled = Vector2D(x: 1.0, y: 4.0)
let afterDoubling = +++toBeDoubled
// toBeDoubled 现在的值为 (2.0, 8.0)
// afterDoubling 现在的值也为 (2.0, 8.0)

自定义中缀运算符的优先级

每个自定义中缀运算符都属于某个优先级组。优先级组指定了这个运算符相对于其他中缀运算符的优先级和结合性。优先级和结合性 中详细阐述了这两个特性是如何对中缀运算符的运算产生影响的。

而没有明确放入某个优先级组的自定义中缀运算符将会被放到一个默认的优先级组内,其优先级高于三元运算符。

以下例子定义了一个新的自定义中缀运算符 +-,此运算符属于 AdditionPrecedence 优先组:

1
2
3
4
5
6
7
8
9
10
infix operator +-: AdditionPrecedence
extension Vector2D {
static func +- (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y - right.y)
}
}
let firstVector = Vector2D(x: 1.0, y: 2.0)
let secondVector = Vector2D(x: 3.0, y: 4.0)
let plusMinusVector = firstVector +- secondVector
// plusMinusVector 是一个 Vector2D 实例,并且它的值为 (4.0, -2.0)

这个运算符把两个向量的 x 值相加,同时从第一个向量的 y 中减去第二个向量的 y 。因为它本质上是属于“相加型”运算符,所以将它放置在 +- 等默认中缀“相加型”运算符相同的优先级组中。关于 Swift 标准库提供的运算符,以及完整的运算符优先级组和结合性设置,请参考 运算符声明。而更多关于优先级组以及自定义操作符和优先级组的语法,请参考 运算符声明

注意

当定义前缀与后缀运算符的时候,我们并没有指定优先级。然而,如果对同一个值同时使用前缀与后缀运算符,则后缀运算符会先参与运算。

留言與分享

swift访问控制

分類 编程语言, swift

访问控制

访问控制可以限定其它源文件或模块中的代码对你的代码的访问级别。这个特性可以让我们隐藏代码的一些实现细节,并且可以为其他人可以访问和使用的代码提供接口。

你可以明确地给单个类型(类、结构体、枚举)设置访问级别,也可以给这些类型的属性、方法、构造器、下标等设置访问级别。协议也可以被限定在一定的范围内使用,包括协议里的全局常量、变量和函数。

Swift 不仅提供了多种不同的访问级别,还为某些典型场景提供了默认的访问级别,这样就不需要我们在每段代码中都申明显式访问级别。其实,如果只是开发一个单一 target 的应用程序,我们完全可以不用显式声明代码的访问级别。

注意

为了简单起见,对于代码中可以设置访问级别的特性(属性、基本类型、函数等),在下面的章节中我们会称之为“实体”。

模块和源文件

Swift 中的访问控制模型基于模块和源文件这两个概念。

模块指的是独立的代码单元,框架或应用程序会作为一个独立的模块来构建和发布。在 Swift 中,一个模块可以使用 import 关键字导入另外一个模块。

在 Swift 中,Xcode 的每个 target(例如框架或应用程序)都被当作独立的模块处理。如果你是为了实现某个通用的功能,或者是为了封装一些常用方法而将代码打包成独立的框架,这个框架就是 Swift 中的一个模块。当它被导入到某个应用程序或者其他框架时,框架内容都将属于这个独立的模块。

源文件就是 Swift 中的源代码文件,它通常属于一个模块,即一个应用程序或者框架。尽管我们一般会将不同的类型分别定义在不同的源文件中,但是同一个源文件也可以包含多个类型、函数之类的定义。

访问级别

Swift 为代码中的实体提供了五种不同的访问级别。这些访问级别不仅与源文件中定义的实体相关,同时也与源文件所属的模块相关。

  • OpenPublic 级别可以让实体被同一模块源文件中的所有实体访问,在模块外也可以通过导入该模块来访问源文件里的所有实体。通常情况下,你会使用 Open 或 Public 级别来指定框架的外部接口。Open 和 Public 的区别在后面会提到。
  • Internal 级别让实体被同一模块源文件中的任何实体访问,但是不能被模块外的实体访问。通常情况下,如果某个接口只在应用程序或框架内部使用,就可以将其设置为 Internal 级别。
  • File-private 限制实体只能在其定义的文件内部访问。如果功能的部分细节只需要在文件内使用时,可以使用 File-private 来将其隐藏。
  • Private 限制实体只能在其定义的作用域,以及同一文件内的 extension 访问。如果功能的部分细节只需要在当前作用域内使用时,可以使用 Private 来将其隐藏。

Open 为最高访问级别(限制最少),Private 为最低访问级别(限制最多)。

Open 只能作用于类和类的成员,它和 Public 的区别如下:

  • Public 或者其它更严访问级别的类,只能在其定义的模块内部被继承。
  • Public 或者其它更严访问级别的类成员,只能在其定义的模块内部的子类中重写。
  • Open 的类,可以在其定义的模块中被继承,也可以在引用它的模块中被继承。
  • Open 的类成员,可以在其定义的模块中子类中重写,也可以在引用它的模块中的子类重写。

把一个类标记为 open,明确的表示你已经充分考虑过外部模块使用此类作为父类的影响,并且设计好了你的类的代码了。

访问级别基本原则

Swift 中的访问级别遵循一个基本原则:实体不能定义在具有更低访问级别(更严格)的实体中

例如:

  • 一个 Public 的变量,其类型的访问级别不能是 Internal,File-private 或是 Private。因为无法保证变量的类型在使用变量的地方也具有访问权限。
  • 函数的访问级别不能高于它的参数类型和返回类型的访问级别。因为这样就会出现函数可以在任何地方被访问,但是它的参数类型和返回类型却不可以的情况。

关于此原则在各种情况下的具体表现,将在下文有所体现。

默认访问级别

如果你没有为代码中的实体显式指定访问级别,那么它们默认为 internal 级别(有一些例外情况,稍后会进行说明)。因此,在大多数情况下,我们不需要显式指定实体的访问级别。

单 target 应用程序的访问级别

当你编写一个单目标应用程序时,应用的所有功能都是为该应用服务,而不需要提供给其他应用或者模块使用,所以我们不需要明确设置访问级别,使用默认的访问级别 Internal 即可。但是,你也可以使用 fileprivate 访问或 private 访问级别,用于隐藏一些功能的实现细节。

框架的访问级别

当你开发框架时,就需要把一些对外的接口定义为 Open 或 Public,以便使用者导入该框架后可以正常使用其功能。这些被你定义为对外的接口,就是这个框架的 API。

注意

框架的内部实现仍然可以使用默认的访问级别 internal,当你需要对框架内部其它部分隐藏细节时可以使用 privatefileprivate。对于框架的对外 API 部分,你就需要将它们设置为 openpublic 了。

单元测试 target 的访问级别

当你的应用程序包含单元测试 target 时,为了测试,测试模块需要访问应用程序模块中的代码。默认情况下只有 openpublic 级别的实体才可以被其他模块访问。然而,如果在导入应用程序模块的语句前使用 @testable 特性,然后在允许测试的编译设置(Build Options -> Enable Testability)下编译这个应用程序模块,单元测试目标就可以访问应用程序模块中所有内部级别的实体。

访问控制语法

通过修饰符 openpublicinternalfileprivateprivate 来声明实体的访问级别:

1
2
3
4
5
6
7
8
9
public class SomePublicClass {}
internal class SomeInternalClass {}
fileprivate class SomeFilePrivateClass {}
private class SomePrivateClass {}

public var somePublicVariable = 0
internal let someInternalConstant = 0
fileprivate func someFilePrivateFunction() {}
private func somePrivateFunction() {}

除非专门指定,否则实体默认的访问级别为 internal,可以查阅 默认访问级别 这一节。这意味着在不使用修饰符显式声明访问级别的情况下,SomeInternalClasssomeInternalConstant 仍然拥有隐式的 internal

1
2
class SomeInternalClass {}   // 隐式 internal
var someInternalConstant = 0 // 隐式 internal

自定义类型

如果想为一个自定义类型指定访问级别,在定义类型时进行指定即可。新类型只能在它的访问级别限制范围内使用。例如,你定义了一个 fileprivate 级别的类,那这个类就只能在定义它的源文件中使用,可以作为属性类型、函数参数类型或者返回类型,等等。

一个类型的访问级别也会影响到类型成员(属性、方法、构造器、下标)的默认访问级别。如果你将类型指定为 private 或者 fileprivate 级别,那么该类型的所有成员的默认访问级别也会变成 private 或者 fileprivate 级别。如果你将类型指定为 internalpublic(或者不明确指定访问级别,而使用默认的 internal ),那么该类型的所有成员的默认访问级别将是 internal

重点

上面提到,一个 public 类型的所有成员的访问级别默认为 internal 级别,而不是 public 级别。如果你想将某个成员指定为 public 级别,那么你必须显式指定。这样做的好处是,在你定义公共接口的时候,可以明确地选择哪些接口是需要公开的,哪些是内部使用的,避免不小心将内部使用的接口公开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SomePublicClass {                  // 显式 public 类
public var somePublicProperty = 0 // 显式 public 类成员
var someInternalProperty = 0 // 隐式 internal 类成员
fileprivate func someFilePrivateMethod() {} // 显式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

class SomeInternalClass { // 隐式 internal 类
var someInternalProperty = 0 // 隐式 internal 类成员
fileprivate func someFilePrivateMethod() {} // 显式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

fileprivate class SomeFilePrivateClass { // 显式 fileprivate 类
func someFilePrivateMethod() {} // 隐式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

private class SomePrivateClass { // 显式 private 类
func somePrivateMethod() {} // 隐式 private 类成员
}

元组类型

元组的访问级别将由元组中访问级别最严格的类型来决定。例如,如果你构建了一个包含两种不同类型的元组,其中一个类型为 internal,另一个类型为 private,那么这个元组的访问级别为 private

注意

元组不同于类、结构体、枚举、函数那样有单独的定义。元组的访问级别是在它被使用时自动推断出的,而无法明确指定。

函数类型

函数的访问级别根据访问级别最严格的参数类型或返回类型的访问级别来决定。但是,如果这种访问级别不符合函数定义所在环境的默认访问级别,那么就需要明确地指定该函数的访问级别。

下面的例子定义了一个名为 someFunction() 的全局函数,并且没有明确地指定其访问级别。也许你会认为该函数应该拥有默认的访问级别 internal,但事实并非如此。事实上,如果按下面这种写法,代码将无法通过编译:

1
2
3
func someFunction() -> (SomeInternalClass, SomePrivateClass) {
// 此处是函数实现部分
}

我们可以看到,这个函数的返回类型是一个元组,该元组中包含两个自定义的类(可查阅 自定义类型)。其中一个类的访问级别是 internal,另一个的访问级别是 private,所以根据元组访问级别的原则,该元组的访问级别是 private(元组的访问级别与元组中访问级别最低的类型一致)。

因为该函数返回类型的访问级别是 private,所以你必须使用 private 修饰符,明确指定该函数的访问级别:

1
2
3
private func someFunction() -> (SomeInternalClass, SomePrivateClass) {
// 此处是函数实现部分
}

将该函数指定为 publicinternal,或者使用默认的访问级别 internal 都是错误的,因为如果把该函数当做 publicinternal 级别来使用的话,可能会无法访问 private 级别的返回值。

枚举类型

枚举成员的访问级别和该枚举类型相同,你不能为枚举成员单独指定不同的访问级别。

比如下面的例子,枚举 CompassPoint 被明确指定为 public,那么它的成员 NorthSouthEastWest 的访问级别同样也是 public

1
2
3
4
5
6
public enum CompassPoint {
case north
case south
case east
case west
}

原始值和关联值

枚举定义中的任何原始值或关联值的类型的访问级别至少不能低于枚举类型的访问级别。例如,你不能在一个 internal 的枚举中定义 private 的原始值类型。

嵌套类型

如果在 private 的类型中定义嵌套类型,那么该嵌套类型就自动拥有 private 访问级别。如果在 public 或者 internal 级别的类型中定义嵌套类型,那么该嵌套类型自动拥有 internal 访问级别。如果想让嵌套类型拥有 public 访问级别,那么需要明确指定该嵌套类型的访问级别。

子类

子类的访问级别不得高于父类的访问级别。例如,父类的访问级别是 internal,子类的访问级别就不能是 public

此外,你可以在符合当前访问级别的条件下重写任意类成员(方法、属性、构造器、下标等)。

可以通过重写为继承来的类成员提供更高的访问级别。下面的例子中,类 A 的访问级别是 public,它包含一个方法 someMethod(),访问级别为 private。类 B 继承自类 A,访问级别为 internal,但是在类 B 中重写了类 A 中访问级别为 private 的方法 someMethod(),并重新指定为 internal 级别。通过这种方式,我们就可以将某类中 private 级别的类成员重新指定为更高的访问级别,以便其他人使用:

1
2
3
4
5
6
7
public class A {
fileprivate func someMethod() {}
}

internal class B: A {
override internal func someMethod() {}
}

我们甚至可以在子类中,用子类成员去访问访问级别更低的父类成员,只要这一操作在相应访问级别的限制范围内(也就是说,在同一源文件中访问父类 private 级别的成员,在同一模块内访问父类 internal 级别的成员):

1
2
3
4
5
6
7
8
9
public class A {
fileprivate func someMethod() {}
}

internal class B: A {
override internal func someMethod() {
super.someMethod()
}
}

因为父类 A 和子类 B 定义在同一个源文件中,所以在子类 B 可以在重写的 someMethod() 方法中调用 super.someMethod()

常量、变量、属性、下标

常量、变量、属性不能拥有比它们的类型更高的访问级别。例如,你不能定义一个 public 级别的属性,但是它的类型却是 private 级别的。同样,下标也不能拥有比索引类型或返回类型更高的访问级别。

如果常量、变量、属性、下标的类型是 private 级别的,那么它们必须明确指定访问级别为 private

1
private var privateInstance = SomePrivateClass()

Getter 和 Setter

常量、变量、属性、下标的 GettersSetters 的访问级别和它们所属类型的访问级别相同。

Setter 的访问级别可以低于对应的 Getter 的访问级别,这样就可以控制变量、属性或下标的读写权限。在 varsubscript 关键字之前,你可以通过 fileprivate(set)private(set)internal(set) 为它们的写入权限指定更低的访问级别。

注意

这个规则同时适用于存储型属性和计算型属性。即使你不明确指定存储型属性的 GetterSetter,Swift 也会隐式地为其创建 GetterSetter,用于访问该属性的后备存储。使用 fileprivate(set)private(set)internal(set) 可以改变 Setter 的访问级别,这对计算型属性也同样适用。

下面的例子中定义了一个名为 TrackedString 的结构体,它记录了 value 属性被修改的次数:

1
2
3
4
5
6
7
8
struct TrackedString {
private(set) var numberOfEdits = 0
var value: String = "" {
didSet {
numberOfEdits += 1
}
}
}

TrackedString 结构体定义了一个用于存储 String 值的属性 value,并将初始值设为 ""(一个空字符串)。该结构体还定义了另一个用于存储 Int 值的属性 numberOfEdits,它用于记录属性 value 被修改的次数。这个功能通过属性 valuedidSet 观察器实现,每当给 value 赋新值时就会调用 didSet 方法,然后将 numberOfEdits 的值加一。

结构体 TrackedString 和它的属性 value 都没有显式地指定访问级别,所以它们都是用默认的访问级别 internal。但是该结构体的 numberOfEdits 属性使用了 private(set) 修饰符,这意味着 numberOfEdits 属性只能在结构体的定义中进行赋值。numberOfEdits 属性的 Getter 依然是默认的访问级别 internal,但是 Setter 的访问级别是 private,这表示该属性只能在内部修改,而在结构体的外部则表现为一个只读属性。

如果你实例化 TrackedString 结构体,并多次对 value 属性的值进行修改,你就会看到 numberOfEdits 的值会随着修改次数而变化:

1
2
3
4
5
6
var stringToEdit = TrackedString()
stringToEdit.value = "This string will be tracked."
stringToEdit.value += " This edit will increment numberOfEdits."
stringToEdit.value += " So will this one."
print("The number of edits is \(stringToEdit.numberOfEdits)")
// 打印“The number of edits is 3”

虽然你可以在其他的源文件中实例化该结构体并且获取到 numberOfEdits 属性的值,但是你不能对其进行赋值。这一限制保护了该记录功能的实现细节,同时还提供了方便的访问方式。

你可以在必要时为 GetterSetter 显式指定访问级别。下面的例子将 TrackedString 结构体明确指定为了 public 访问级别。结构体的成员(包括 numberOfEdits 属性)拥有默认的访问级别 internal。你可以结合 publicprivate(set) 修饰符把结构体中的 numberOfEdits 属性的 Getter 的访问级别设置为 public,而 Setter 的访问级别设置为 private

1
2
3
4
5
6
7
8
9
public struct TrackedString {
public private(set) var numberOfEdits = 0
public var value: String = "" {
didSet {
numberOfEdits += 1
}
}
public init() {}
}

构造器

自定义构造器的访问级别可以低于或等于其所属类型的访问级别。唯一的例外是 必要构造器,它的访问级别必须和所属类型的访问级别相同。

如同函数或方法的参数,构造器参数的访问级别也不能低于构造器本身的访问级别。

默认构造器

默认构造器 所述,Swift 会为结构体和类提供一个默认的无参数的构造器,只要它们为所有存储型属性设置了默认初始值,并且未提供自定义的构造器。

默认构造器的访问级别与所属类型的访问级别相同,除非类型的访问级别是 public。如果一个类型被指定为 public 级别,那么默认构造器的访问级别将为 internal。如果你希望一个 public 级别的类型也能在其他模块中使用这种无参数的默认构造器,你只能自己提供一个 public 访问级别的无参数构造器。

结构体默认的成员逐一构造器

如果结构体中任意存储型属性的访问级别为 private,那么该结构体默认的成员逐一构造器的访问级别就是 private。否则,这种构造器的访问级别依然是 internal

如同前面提到的默认构造器,如果你希望一个 public 级别的结构体也能在其他模块中使用其默认的成员逐一构造器,你依然只能自己提供一个 public 访问级别的成员逐一构造器。

协议

如果想为一个协议类型明确地指定访问级别,在定义协议时指定即可。这将限制该协议只能在适当的访问级别范围内被遵循。

协议中的每一个要求都具有和该协议相同的访问级别。你不能将协议中的要求设置为其他访问级别。这样才能确保该协议的所有要求对于任意遵循者都将可用。

注意

如果你定义了一个 public 访问级别的协议,那么该协议的所有实现也会是 public 访问级别。这一点不同于其他类型,例如,当类型是 public 访问级别时,其成员的访问级别却只是 internal

协议继承

如果定义了一个继承自其他协议的新协议,那么新协议拥有的访问级别最高也只能和被继承协议的访问级别相同。例如,你不能将继承自 internal 协议的新协议定义为 public 协议。

协议遵循

一个类型可以遵循比它级别更低的协议。例如,你可以定义一个 public 级别类型,它能在别的模块中使用,但是如果它遵循一个 internal 协议,这个遵循的部分就只能在这个 internal 协议所在的模块中使用。

遵循协议时的上下文级别是类型和协议中级别最小的那个。如果一个类型是 public 级别,但它要遵循的协议是 internal 级别,那么这个类型对该协议的遵循上下文就是 internal 级别。

当你编写或扩展一个类型让它遵循一个协议时,你必须确保该类型对协议的每一个要求的实现,至少与遵循协议的上下文级别一致。例如,一个 public 类型遵循一个 internal 协议,这个类型对协议的所有实现至少都应是 internal 级别的。

注意

Swift 和 Objective-C 一样,协议遵循是全局的,也就是说,在同一程序中,一个类型不可能用两种不同的方式实现同一个协议。

Extension

Extension 可以在访问级别允许的情况下对类、结构体、枚举进行扩展。Extension 的成员具有和原始类型成员一致的访问级别。例如,你使用 extension 扩展了一个 public 或者 internal 类型,extension 中的成员就默认使用 internal 访问级别,和原始类型中的成员一致。如果你使用 extension 扩展了一个 private 类型,则 extension 的成员默认使用 private 访问级别。

或者,你可以明确指定 extension 的访问级别(例如,private extension),从而给该 extension 中的所有成员指定一个新的默认访问级别。这个新的默认访问级别仍然可以被单独指定的访问级别所覆盖。

如果你使用 extension 来遵循协议的话,就不能显式地声明 extension 的访问级别。extension 每个 protocol 要求的实现都默认使用 protocol 的访问级别。

Extension 的私有成员

扩展同一文件内的类,结构体或者枚举,extension 里的代码会表现得跟声明在原类型里的一模一样。也就是说你可以这样:

  • 在类型的声明里声明一个私有成员,在同一文件的 extension 里访问。
  • 在 extension 里声明一个私有成员,在同一文件的另一个 extension 里访问。
  • 在 extension 里声明一个私有成员,在同一文件的类型声明里访问。

这意味着你可以像组织的代码去使用 extension,而且不受私有成员的影响。例如,给定下面这样一个简单的协议:

1
2
3
protocol SomeProtocol {
func doSomething()
}

你可以使用 extension 来遵循协议,就像这样:

1
2
3
4
5
6
7
8
9
struct SomeStruct {
private var privateVariable = 12
}

extension SomeStruct: SomeProtocol {
func doSomething() {
print(privateVariable)
}
}

泛型

泛型类型或泛型函数的访问级别取决于泛型类型或泛型函数本身的访问级别,还需结合类型参数的类型约束的访问级别,根据这些访问级别中的最低访问级别来确定。

类型别名

你定义的任何类型别名都会被当作不同的类型,以便于进行访问控制。类型别名的访问级别不可高于其表示的类型的访问级别。例如,private 级别的类型别名可以作为 privatefile-privateinternalpublic 或者 open 类型的别名,但是 public 级别的类型别名只能作为 public 类型的别名,不能作为 internalfile-privateprivate 类型的别名。

注意

这条规则也适用于为满足协议遵循而将类型别名用于关联类型的情况。

留言與分享

swift内存安全

分類 编程语言, swift

内存安全

默认情况下,Swift 会阻止你代码里不安全的行为。例如,Swift 会保证变量在使用之前就完成初始化,在内存被回收之后就无法被访问,并且数组的索引会做越界检查。

Swift 也保证同时访问同一块内存时不会冲突,通过约束代码里对于存储地址的写操作,去获取那一块内存的访问独占权。因为 Swift 自动管理内存,所以大部分时候你完全不需要考虑内存访问的事情。然而,理解潜在的冲突也是很重要的,可以避免你写出访问冲突的代码。而如果你的代码确实存在冲突,那在编译时或者运行时就会得到错误。

理解内存访问冲突

内存的访问,会发生在你给变量赋值,或者传递参数给函数时。例如,下面的代码就包含了读和写的访问:

1
2
3
4
5
// 向 one 所在的内存区域发起一次写操作
var one = 1

// 向 one 所在的内存区域发起一次读操作
print("We're number \(one)!")

内存访问的冲突会发生在你的代码尝试同时访问同一个存储地址的时侯。同一个存储地址的多个访问同时发生会造成不可预计或不一致的行为。在 Swift 里,有很多修改值的行为都会持续好几行代码,在修改值的过程中进行访问是有可能发生的。

你可以思考一下预算表更新的过程,会看到同样的问题。更新预算表总共有两步:首先你把预算项的名字和费用加上,然后再更新总数来反映预算表的现况。在更新之前和之后,你都可以从预算表里读取任何信息并获得正确的答案,就像下面展示的那样。

而当你添加预算项进入表里的时候,它只是在一个临时的,错误的状态,因为总数还没有被更新。在添加数据的过程中读取总数就会读取到错误的信息。

这个例子也演示了你在修复内存访问冲突时会遇到的问题:有时修复的方式会有很多种,但哪一种是正确的就不总是那么明显了。在这个例子里,根据你是否需要更新后的总数,$5 和 $320 都可能是正确的值。在你修复访问冲突之前,你需要决定它的倾向。

注意

如果你写过并发和多线程的代码,内存访问冲突也许是同样的问题。然而,这里访问冲突的讨论是在单线程的情境下讨论的,并没有使用并发或者多线程。

如果你曾经在单线程代码里有访问冲突,Swift 可以保证你在编译或者运行时会得到错误。对于多线程的代码,可以使用 Thread Sanitizer 去帮助检测多线程的冲突。

内存访问性质

内存访问冲突时,要考虑内存访问上下文中的这三个性质:访问是读还是写,访问的时长,以及被访问的存储地址。特别是,冲突会发生在当你有两个访问符合下列的情况:

  • 至少有一个是写访问
  • 它们访问的是同一个存储地址
  • 它们的访问在时间线上部分重叠

读和写访问的区别很明显:一个写访问会改变存储地址,而读操作不会。存储地址是指向正在访问的东西(例如一个变量,常量或者属性)的位置的值 。内存访问的时长要么是瞬时的,要么是长期的。

如果一个访问不可能在其访问期间被其它代码访问,那么就是一个瞬时访问。正常来说,两个瞬时访问是不可能同时发生的。大多数内存访问都是瞬时的。例如,下面列举的所有读和写访问都是瞬时的:

1
2
3
4
5
6
7
8
func oneMore(than number: Int) -> Int {
return number + 1
}

var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// 打印“2”

然而,有几种被称为长期访问的内存访问方式,会在别的代码执行时持续进行。瞬时访问和长期访问的区别在于别的代码有没有可能在访问期间同时访问,也就是在时间线上的重叠。一个长期访问可以被别的长期访问或瞬时访问重叠。

重叠的访问主要出现在使用 in-out 参数的函数和方法或者结构体的 mutating 方法里。Swift 代码里典型的长期访问会在后面进行讨论。

In-Out 参数的访问冲突

一个函数会对它所有的 in-out 参数进行长期写访问。in-out 参数的写访问会在所有非 in-out 参数处理完之后开始,直到函数执行完毕为止。如果有多个 in-out 参数,则写访问开始的顺序与参数的顺序一致。

长期访问的存在会造成一个结果,你不能在访问以 in-out 形式传入后的原变量,即使作用域原则和访问权限允许——任何访问原变量的行为都会造成冲突。例如:

1
2
3
4
5
6
7
8
var stepSize = 1

func increment(_ number: inout Int) {
number += stepSize
}

increment(&stepSize)
// 错误:stepSize 访问冲突

在上面的代码里,stepSize 是一个全局变量,并且它可以在 increment(_:) 里正常访问。然而,对于 stepSize 的读访问与 number 的写访问重叠了。就像下面展示的那样,numberstepSize 都指向了同一个存储地址。同一块内存的读和写访问重叠了,就此产生了冲突。

解决这个冲突的一种方式,是显示拷贝一份 stepSize

1
2
3
4
5
6
7
// 显式拷贝
var copyOfStepSize = stepSize
increment(&copyOfStepSize)

// 更新原来的值
stepSize = copyOfStepSize
// stepSize 现在的值是 2

当你在调用 increment(_:) 之前做一份拷贝,显然 copyOfStepSize 就会根据当前的 stepSize 增加。读访问在写操作之前就已经结束了,所以不会有冲突。

长期写访问的存在还会造成另一种结果,往同一个函数的多个 in-out 参数里传入同一个变量也会产生冲突,例如:

1
2
3
4
5
6
7
8
9
10
func balance(_ x: inout Int, _ y: inout Int) {
let sum = x + y
x = sum / 2
y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore) // 正常
balance(&playerOneScore, &playerOneScore)
// 错误:playerOneScore 访问冲突

上面的 balance(_:_:) 函数会将传入的两个参数平均化。将 playerOneScoreplayerTwoScore 作为参数传入不会产生错误 —— 有两个访问重叠了,但它们访问的是不同的内存位置。相反,将 playerOneScore 作为参数同时传入就会产生冲突,因为它会发起两个写访问,同时访问同一个的存储地址。

注意

因为操作符也是函数,它们也会对 in-out 参数进行长期访问。例如,假设 balance(_:_:) 是一个名为 <^> 的操作符函数,那么 playerOneScore <^> playerOneScore 也会造成像 balance(&playerOneScore, &playerOneScore) 一样的冲突。

方法里 self 的访问冲突

一个结构体的 mutating 方法会在调用期间对 self 进行写访问。例如,想象一下这么一个游戏,每一个玩家都有血量,受攻击时血量会下降,并且有敌人的数量,使用特殊技能时会减少敌人数量。

1
2
3
4
5
6
7
8
9
10
struct Player {
var name: String
var health: Int
var energy: Int

static let maxHealth = 10
mutating func restoreHealth() {
health = Player.maxHealth
}
}

在上面的 restoreHealth() 方法里,一个对于 self 的写访问会从方法开始直到方法 return。在这种情况下,restoreHealth() 里的其它代码不可以对 Player 实例的属性发起重叠的访问。下面的 shareHealth(with:) 方法接受另一个 Player 的实例作为 in-out 参数,产生了访问重叠的可能性。

1
2
3
4
5
6
7
8
9
extension Player {
mutating func shareHealth(with teammate: inout Player) {
balance(&teammate.health, &health)
}
}

var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria) // 正常

上面的例子里,调用 shareHealth(with:) 方法去把 oscar 玩家的血量分享给 maria 玩家并不会造成冲突。在方法调用期间会对 oscar 发起写访问,因为在 mutating 方法里 self 就是 oscar,同时对于 maria 也会发起写访问,因为 maria 作为 in-out 参数传入。过程如下,它们会访问内存的不同位置。即使两个写访问重叠了,它们也不会冲突。

当然,如果你将 oscar 作为参数传入 shareHealth(with:) 里,就会产生冲突:

1
2
oscar.shareHealth(with: &oscar)
// 错误:oscar 访问冲突

mutating 方法在调用期间需要对 self 发起写访问,而同时 in-out 参数也需要写访问。在方法里,selfteammate 都指向了同一个存储地址——就像下面展示的那样。对于同一块内存同时进行两个写访问,并且它们重叠了,就此产生了冲突。

属性的访问冲突

如结构体,元组和枚举的类型都是由多个独立的值组成的,例如结构体的属性或元组的元素。因为它们都是值类型,修改值的任何一部分都是对于整个值的修改,意味着其中一个属性的读或写访问都需要访问整一个值。例如,元组元素的写访问重叠会产生冲突:

1
2
3
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// 错误:playerInformation 的属性访问冲突

上面的例子里,传入同一元组的元素对 balance(_:_:) 进行调用,产生了冲突,因为 playerInformation 的访问产生了写访问重叠。playerInformation.healthplayerInformation.energy 都被作为 in-out 参数传入,意味着 balance(_:_:) 需要在函数调用期间对它们发起写访问。任何情况下,对于元组元素的写访问都需要对整个元组发起写访问。这意味着对于 playerInfomation 发起的两个写访问重叠了,造成冲突。

下面的代码展示了一样的错误,对于一个存储在全局变量里的结构体属性的写访问重叠了。

1
2
var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy) // 错误

在实践中,大多数对于结构体属性的访问都会安全的重叠。例如,将上面例子里的变量 holly 改为本地变量而非全局变量,编译器就会可以保证这个重叠访问是安全的:

1
2
3
4
func someFunction() {
var oscar = Player(name: "Oscar", health: 10, energy: 10)
balance(&oscar.health, &oscar.energy) // 正常
}

上面的例子里,oscarhealthenergy 都作为 in-out 参数传入了 balance(_:_:) 里。编译器可以保证内存安全,因为两个存储属性任何情况下都不会相互影响。

限制结构体属性的重叠访问对于保证内存安全不是必要的。保证内存安全是必要的,但因为访问独占权的要求比内存安全还要更严格——意味着即使有些代码违反了访问独占权的原则,也是内存安全的,所以如果编译器可以保证这种非专属的访问是安全的,那 Swift 就会允许这种行为的代码运行。特别是当你遵循下面的原则时,它可以保证结构体属性的重叠访问是安全的:

  • 你访问的是实例的存储属性,而不是计算属性或类的属性
  • 结构体是本地变量的值,而非全局变量
  • 结构体要么没有被闭包捕获,要么只被非逃逸闭包捕获了

如果编译器无法保证访问的安全性,它就不会允许那次访问。

留言與分享

swift自动引用计数

分類 编程语言, swift

自动引用计数

Swift 使用*自动引用计数(ARC)*机制来跟踪和管理你的应用程序的内存。通常情况下,Swift 内存管理机制会一直起作用,你无须自己来考虑内存的管理。ARC 会在类的实例不再被使用时,自动释放其占用的内存。

然而在少数情况下,为了能帮助你管理内存,ARC 需要更多的,代码之间关系的信息。本章描述了这些情况,并且为你示范怎样才能使 ARC 来管理你的应用程序的所有内存。在 Swift 使用 ARC 与在 Obejctive-C 中使用 ARC 非常类似,具体请参考 过渡到 ARC 的发布说明

注意

引用计数仅仅应用于类的实例。结构体和枚举类型是值类型,不是引用类型,也不是通过引用的方式存储和传递。

自动引用计数的工作机制

当你每次创建一个类的新的实例的时候,ARC 会分配一块内存来储存该实例信息。内存中会包含实例的类型信息,以及这个实例所有相关的存储型属性的值。

此外,当实例不再被使用时,ARC 释放实例所占用的内存,并让释放的内存能挪作他用。这确保了不再被使用的实例,不会一直占用内存空间。

然而,当 ARC 收回和释放了正在被使用中的实例,该实例的属性和方法将不能再被访问和调用。实际上,如果你试图访问这个实例,你的应用程序很可能会崩溃。

为了确保使用中的实例不会被销毁,ARC 会跟踪和计算每一个实例正在被多少属性,常量和变量所引用。哪怕实例的引用数为 1,ARC 都不会销毁这个实例。

为了使上述成为可能,无论你将实例赋值给属性、常量或变量,它们都会创建此实例的强引用。之所以称之为“强”引用,是因为它会将实例牢牢地保持住,只要强引用还在,实例是不允许被销毁的。

自动引用计数实践

下面的例子展示了自动引用计数的工作机制。例子以一个简单的 Person 类开始,并定义了一个叫 name 的常量属性:

1
2
3
4
5
6
7
8
9
10
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}

Person 类有一个构造器,此构造器为实例的 name 属性赋值,并打印一条消息以表明初始化过程生效。Person 类也拥有一个析构器,这个析构器会在实例被销毁时打印一条消息。

接下来的代码片段定义了三个类型为 Person? 的变量,用来按照代码片段中的顺序,为新的 Person 实例建立多个引用。由于这些变量是被定义为可选类型(Person?,而不是 Person),它们的值会被自动初始化为 nil,目前还不会引用到 Person 类的实例。

1
2
3
var reference1: Person?
var reference2: Person?
var reference3: Person?

现在你可以创建 Person 类的新实例,并且将它赋值给三个变量中的一个:

1
2
reference1 = Person(name: "John Appleseed")
// 打印“John Appleseed is being initialized”

应当注意到当你调用 Person 类的构造器的时候,"John Appleseed is being initialized" 会被打印出来。由此可以确定构造器被执行。

由于 Person 类的新实例被赋值给了 reference1 变量,所以 reference1Person 类的新实例之间建立了一个强引用。正是因为这一个强引用,ARC 会保证 Person 实例被保持在内存中不被销毁。

如果你将同一个 Person 实例也赋值给其他两个变量,该实例又会多出两个强引用:

1
2
reference2 = reference1
reference3 = reference1

现在这一个 Person 实例已经有三个强引用了。

如果你通过给其中两个变量赋值 nil 的方式断开两个强引用(包括最先的那个强引用),只留下一个强引用,Person 实例不会被销毁:

1
2
reference1 = nil
reference2 = nil

在你清楚地表明不再使用这个 Person 实例时,即第三个也就是最后一个强引用被断开时,ARC 会销毁它:

1
2
reference3 = nil
// 打印“John Appleseed is being deinitialized”

类实例之间的循环强引用

在上面的例子中,ARC 会跟踪你所新创建的 Person 实例的引用数量,并且会在 Person 实例不再被需要时销毁它。

然而,我们可能会写出一个类实例的强引用数永远不能变成 0 的代码。如果两个类实例互相持有对方的强引用,因而每个实例都让对方一直存在,就是这种情况。这就是所谓的循环强引用

你可以通过定义类之间的关系为弱引用或无主引用,以替代强引用,从而解决循环强引用的问题。具体的过程在 解决类实例之间的循环强引用 中有描述。不管怎样,在你学习怎样解决循环强引用之前,很有必要了解一下它是怎样产生的。

下面展示了一个不经意产生循环强引用的例子。例子定义了两个类:PersonApartment,用来建模公寓和它其中的居民:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}

class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
var tenant: Person?
deinit { print("Apartment \(unit) is being deinitialized") }
}

每一个 Person 实例有一个类型为 String,名字为 name 的属性,并有一个可选的初始化为 nilapartment 属性。apartment 属性是可选的,因为一个人并不总是拥有公寓。

类似的,每个 Apartment 实例有一个叫 unit,类型为 String 的属性,并有一个可选的初始化为 niltenant 属性。tenant 属性是可选的,因为一栋公寓并不总是有居民。

这两个类都定义了析构器,用以在类实例被析构的时候输出信息。这让你能够知晓 PersonApartment 的实例是否像预期的那样被销毁。

接下来的代码片段定义了两个可选类型的变量 johnunit4A,并分别被设定为下面的 ApartmentPerson 的实例。这两个变量都被初始化为 nil,这正是可选类型的优点:

1
2
var john: Person?
var unit4A: Apartment?

现在你可以创建特定的 PersonApartment 实例并将赋值给 johnunit4A 变量:

1
2
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

在两个实例被创建和赋值后,下图表现了强引用的关系。变量 john 现在有一个指向 Person 实例的强引用,而变量 unit4A 有一个指向 Apartment 实例的强引用:

现在你能够将这两个实例关联在一起,这样人就能有公寓住了,而公寓也有了房客。注意感叹号是用来展开和访问可选变量 johnunit4A 中的实例,这样实例的属性才能被赋值:

1
2
john!.apartment = unit4A
unit4A!.tenant = john

在将两个实例联系在一起之后,强引用的关系如图所示:

不幸的是,这两个实例关联后会产生一个循环强引用。Person 实例现在有了一个指向 Apartment 实例的强引用,而 Apartment 实例也有了一个指向 Person 实例的强引用。因此,当你断开 johnunit4A 变量所持有的强引用时,引用计数并不会降为 0,实例也不会被 ARC 销毁:

1
2
john = nil
unit4A = nil

注意,当你把这两个变量设为 nil 时,没有任何一个析构器被调用。循环强引用会一直阻止 PersonApartment 类实例的销毁,这就在你的应用程序中造成了内存泄漏。

在你将 johnunit4A 赋值为 nil 后,强引用关系如下图:

PersonApartment 实例之间的强引用关系保留了下来并且不会被断开。

解决实例之间的循环强引用

Swift 提供了两种办法用来解决你在使用类的属性时所遇到的循环强引用问题:弱引用(weak reference)和无主引用(unowned reference)。

弱引用和无主引用允许循环引用中的一个实例引用另一个实例而保持强引用。这样实例能够互相引用而不产生循环强引用。

当其他的实例有更短的生命周期时,使用弱引用,也就是说,当其他实例析构在先时。在上面公寓的例子中,很显然一个公寓在它的生命周期内会在某个时间段没有它的主人,所以一个弱引用就加在公寓类里面,避免循环引用。相比之下,当其他实例有相同的或者更长生命周期时,请使用无主引用。

弱引用

弱引用不会对其引用的实例保持强引用,因而不会阻止 ARC 销毁被引用的实例。这个特性阻止了引用变为循环强引用。声明属性或者变量时,在前面加上 weak 关键字表明这是一个弱引用。

因为弱引用不会保持所引用的实例,即使引用存在,实例也有可能被销毁。因此,ARC 会在引用的实例被销毁后自动将其弱引用赋值为 nil。并且因为弱引用需要在运行时允许被赋值为 nil,所以它们会被定义为可选类型变量,而不是常量。

你可以像其他可选值一样,检查弱引用的值是否存在,你将永远不会访问已销毁的实例的引用。

注意

当 ARC 设置弱引用为 nil 时,属性观察不会被触发。

下面的例子跟上面 PersonApartment 的例子一致,但是有一个重要的区别。这一次,Apartmenttenant 属性被声明为弱引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}

class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
weak var tenant: Person?
deinit { print("Apartment \(unit) is being deinitialized") }
}

然后跟之前一样,建立两个变量(johnunit4A)之间的强引用,并关联两个实例:

1
2
3
4
5
6
7
8
var john: Person?
var unit4A: Apartment?

john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

john!.apartment = unit4A
unit4A!.tenant = john

现在,两个关联在一起的实例的引用关系如下图所示:

Person 实例依然保持对 Apartment 实例的强引用,但是 Apartment 实例只持有对 Person 实例的弱引用。这意味着当你通过把 john 变量赋值为 nil 而断开其所保持的强引用时,再也没有指向 Person 实例的强引用了:

1
2
john = nil
// 打印“John Appleseed is being deinitialized”

由于再也没有指向 Person 实例的强引用,该实例会被销毁,且 tenant 属性会被赋值为 nil

唯一剩下的指向 Apartment 实例的强引用来自于变量 unit4A。如果你断开这个强引用,再也没有指向 Apartment 实例的强引用了:

1
2
unit4A = nil
// 打印“Apartment 4A is being deinitialized”

由于再也没有指向 Person 实例的强引用,该实例会被销毁:

注意

在使用垃圾收集的系统里,弱指针有时用来实现简单的缓冲机制,因为没有强引用的对象只会在内存压力触发垃圾收集时才被销毁。但是在 ARC 中,一旦值的最后一个强引用被移除,就会被立即销毁,这导致弱引用并不适合上面的用途。

无主引用

和弱引用类似,无主引用不会牢牢保持住引用的实例。和弱引用不同的是,无主引用在其他实例有相同或者更长的生命周期时使用。你可以在声明属性或者变量时,在前面加上关键字 unowned 表示这是一个无主引用。

无主引用通常都被期望拥有值。不过 ARC 无法在实例被销毁后将无主引用设为 nil,因为非可选类型的变量不允许被赋值为 nil

重点

使用无主引用,你必须确保引用始终指向一个未销毁的实例。

如果你试图在实例被销毁后,访问该实例的无主引用,会触发运行时错误。

下面的例子定义了两个类,CustomerCreditCard,模拟了银行客户和客户的信用卡。这两个类中,每一个都将另外一个类的实例作为自身的属性。这种关系可能会造成循环强引用。

CustomerCreditCard 之间的关系与前面弱引用例子中 ApartmentPerson 的关系略微不同。在这个数据模型中,一个客户可能有或者没有信用卡,但是一张信用卡总是关联着一个客户。为了表示这种关系,Customer 类有一个可选类型的 card 属性,但是 CreditCard 类有一个非可选类型的 customer 属性。

此外,只能通过将一个 number 值和 customer 实例传递给 CreditCard 构造器的方式来创建 CreditCard 实例。这样可以确保当创建 CreditCard 实例时总是有一个 customer 实例与之关联。

由于信用卡总是关联着一个客户,因此将 customer 属性定义为无主引用,用以避免循环强引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
deinit { print("\(name) is being deinitialized") }
}

class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
deinit { print("Card #\(number) is being deinitialized") }
}

注意

CreditCard 类的 number 属性被定义为 UInt64 类型而不是 Int 类型,以确保 number 属性的存储量在 32 位和 64 位系统上都能足够容纳 16 位的卡号。

下面的代码片段定义了一个叫 john 的可选类型 Customer 变量,用来保存某个特定客户的引用。由于是可选类型,所以变量被初始化为 nil

1
var john: Customer?

现在你可以创建 Customer 类的实例,用它初始化 CreditCard 实例,并将新创建的 CreditCard 实例赋值为客户的 card 属性:

1
2
john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)

在你关联两个实例后,它们的引用关系如下图所示:

Customer 实例持有对 CreditCard 实例的强引用,而 CreditCard 实例持有对 Customer 实例的无主引用。

由于 customer 的无主引用,当你断开 john 变量持有的强引用时,再也没有指向 Customer 实例的强引用了:

由于再也没有指向 Customer 实例的强引用,该实例被销毁了。其后,再也没有指向 CreditCard 实例的强引用,该实例也随之被销毁了:

1
2
3
john = nil
// 打印“John Appleseed is being deinitialized”
// 打印“Card #1234567890123456 is being deinitialized”

最后的代码展示了在 john 变量被设为 nilCustomer 实例和 CreditCard 实例的析构器都打印出了“销毁”的信息。

注意

上面的例子展示了如何使用安全的无主引用。对于需要禁用运行时的安全检查的情况(例如,出于性能方面的原因),Swift 还提供了不安全的无主引用。与所有不安全的操作一样,你需要负责检查代码以确保其安全性。 你可以通过 unowned(unsafe) 来声明不安全无主引用。如果你试图在实例被销毁后,访问该实例的不安全无主引用,你的程序会尝试访问该实例之前所在的内存地址,这是一个不安全的操作。

无主引用和隐式解包可选值属性

上面弱引用和无主引用的例子涵盖了两种常用的需要打破循环强引用的场景。

PersonApartment 的例子展示了两个属性的值都允许为 nil,并会潜在的产生循环强引用。这种场景最适合用弱引用来解决。

CustomerCreditCard 的例子展示了一个属性的值允许为 nil,而另一个属性的值不允许为 nil,这也可能会产生循环强引用。这种场景最适合通过无主引用来解决。

然而,存在着第三种场景,在这种场景中,两个属性都必须有值,并且初始化完成后永远不会为 nil。在这种场景中,需要一个类使用无主属性,而另外一个类使用隐式解包可选值属性。

这使两个属性在初始化完成后能被直接访问(不需要可选展开),同时避免了循环引用。这一节将为你展示如何建立这种关系。

下面的例子定义了两个类,CountryCity,每个类将另外一个类的实例保存为属性。在这个模型中,每个国家必须有首都,每个城市必须属于一个国家。为了实现这种关系,Country 类拥有一个 capitalCity 属性,而 City 类有一个 country 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Country {
let name: String
var capitalCity: City!
init(name: String, capitalName: String) {
self.name = name
self.capitalCity = City(name: capitalName, country: self)
}
}

class City {
let name: String
unowned let country: Country
init(name: String, country: Country) {
self.name = name
self.country = country
}
}

为了建立两个类的依赖关系,City 的构造器接受一个 Country 实例作为参数,并且将实例保存到 country 属性。

Country 的构造器调用了 City 的构造器。然而,只有 Country 的实例完全初始化后,Country 的构造器才能把 self 传给 City 的构造器。在 两段式构造过程 中有具体描述。

为了满足这种需求,通过在类型结尾处加上感叹号(City!)的方式,将 CountrycapitalCity 属性声明为隐式解包可选值类型的属性。这意味着像其他可选类型一样,capitalCity 属性的默认值为 nil,但是不需要展开它的值就能访问它。在 隐式解包可选值 中有描述。

由于 capitalCity 默认值为 nil,一旦 Country 的实例在构造器中给 name 属性赋值后,整个初始化过程就完成了。这意味着一旦 name 属性被赋值后,Country 的构造器就能引用并传递隐式的 selfCountry 的构造器在赋值 capitalCity 时,就能将 self 作为参数传递给 City 的构造器。

以上的意义在于你可以通过一条语句同时创建 CountryCity 的实例,而不产生循环强引用,并且 capitalCity 的属性能被直接访问,而不需要通过感叹号来展开它的可选值:

1
2
3
var country = Country(name: "Canada", capitalName: "Ottawa")
print("\(country.name)'s capital city is called \(country.capitalCity.name)")
// 打印“Canada's capital city is called Ottawa”

在上面的例子中,使用隐式解包可选值值意味着满足了类的构造器的两个构造阶段的要求。capitalCity 属性在初始化完成后,能像非可选值一样使用和存取,同时还避免了循环强引用。

闭包的循环强引用

前面我们看到了循环强引用是在两个类实例属性互相保持对方的强引用时产生的,还知道了如何用弱引用和无主引用来打破这些循环强引用。

循环强引用还会发生在当你将一个闭包赋值给类实例的某个属性,并且这个闭包体中又使用了这个类实例时。这个闭包体中可能访问了实例的某个属性,例如 self.someProperty,或者闭包中调用了实例的某个方法,例如 self.someMethod()。这两种情况都导致了闭包“捕获”self,从而产生了循环强引用。

循环强引用的产生,是因为闭包和类相似,都是引用类型。当你把一个闭包赋值给某个属性时,你是将这个闭包的引用赋值给了属性。实质上,这跟之前的问题是一样的——两个强引用让彼此一直有效。但是,和两个类实例不同,这次一个是类实例,另一个是闭包。

Swift 提供了一种优雅的方法来解决这个问题,称之为 闭包捕获列表(closure capture list)。同样的,在学习如何用闭包捕获列表打破循环强引用之前,先来了解一下这里的循环强引用是如何产生的,这对我们很有帮助。

下面的例子为你展示了当一个闭包引用了 self 后是如何产生一个循环强引用的。例子中定义了一个叫 HTMLElement 的类,用一种简单的模型表示 HTML 文档中的一个单独的元素:

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

let name: String
let text: String?

lazy var asHTML: () -> String = {
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}

init(name: String, text: String? = nil) {
self.name = name
self.text = text
}

deinit {
print("\(name) is being deinitialized")
}

}

HTMLElement 类定义了一个 name 属性来表示这个元素的名称,例如代表头部元素的 "h1",代表段落的 "p",或者代表换行的 "br"HTMLElement 还定义了一个可选属性 text,用来设置 HTML 元素呈现的文本。

除了上面的两个属性,HTMLElement 还定义了一个 lazy 属性 asHTML。这个属性引用了一个将 nametext 组合成 HTML 字符串片段的闭包。该属性是 Void -> String 类型,或者可以理解为“一个没有参数,返回 String 的函数”。

默认情况下,闭包赋值给了 asHTML 属性,这个闭包返回一个代表 HTML 标签的字符串。如果 text 值存在,该标签就包含可选值 text;如果 text 不存在,该标签就不包含文本。对于段落元素,根据 text"some text" 还是 nil,闭包会返回 "<p>some text</p>" 或者 "<p />"

可以像实例方法那样去命名、使用 asHTML 属性。然而,由于 asHTML 是闭包而不是实例方法,如果你想改变特定 HTML 元素的处理方式的话,可以用自定义的闭包来取代默认值。

例如,可以将一个闭包赋值给 asHTML 属性,这个闭包能在 text 属性是 nil 时使用默认文本,这是为了避免返回一个空的 HTML 标签:

1
2
3
4
5
6
7
let heading = HTMLElement(name: "h1")
let defaultText = "some default text"
heading.asHTML = {
return "<\(heading.name)>\(heading.text ?? defaultText)</\(heading.name)>"
}
print(heading.asHTML())
// 打印“<h1>some default text</h1>”

注意

asHTML 声明为 lazy 属性,因为只有当元素确实需要被处理为 HTML 输出的字符串时,才需要使用 asHTML。也就是说,在默认的闭包中可以使用 self,因为只有当初始化完成以及 self 确实存在后,才能访问 lazy 属性。

HTMLElement 类只提供了一个构造器,通过 nametext(如果有的话)参数来初始化一个新元素。该类也定义了一个析构器,当 HTMLElement 实例被销毁时,打印一条消息。

下面的代码展示了如何用 HTMLElement 类创建实例并打印消息:

1
2
3
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// 打印“<p>hello, world</p>”

注意

上面的 paragraph 变量定义为可选类型的 HTMLElement,因此我们可以赋值 nil 给它来演示循环强引用。

不幸的是,上面写的 HTMLElement 类产生了类实例和作为 asHTML 默认值的闭包之间的循环强引用。循环强引用如下图所示:

实例的 asHTML 属性持有闭包的强引用。但是,闭包在其闭包体内使用了 self(引用了 self.nameself.text),因此闭包捕获了 self,这意味着闭包又反过来持有了 HTMLElement 实例的强引用。这样两个对象就产生了循环强引用。(更多关于闭包捕获值的信息,请参考 值捕获)。

注意

虽然闭包多次使用了 self,它只捕获 HTMLElement 实例的一个强引用。

如果设置 paragraph 变量为 nil,打破它持有的 HTMLElement 实例的强引用,HTMLElement 实例和它的闭包都不会被销毁,也是因为循环强引用:

1
paragraph = nil

注意,HTMLElement 的析构器中的消息并没有被打印,证明了 HTMLElement 实例并没有被销毁。

解决闭包的循环强引用

在定义闭包时同时定义捕获列表作为闭包的一部分,通过这种方式可以解决闭包和类实例之间的循环强引用。捕获列表定义了闭包体内捕获一个或者多个引用类型的规则。跟解决两个类实例间的循环强引用一样,声明每个捕获的引用为弱引用或无主引用,而不是强引用。应当根据代码关系来决定使用弱引用还是无主引用。

注意

Swift 有如下要求:只要在闭包内使用 self 的成员,就要用 self.someProperty 或者 self.someMethod()(而不只是 somePropertysomeMethod())。这提醒你可能会一不小心就捕获了 self

定义捕获列表

捕获列表中的每一项都由一对元素组成,一个元素是 weakunowned 关键字,另一个元素是类实例的引用(例如 self)或初始化过的变量(如 delegate = self.delegate!)。这些项在方括号中用逗号分开。

如果闭包有参数列表和返回类型,把捕获列表放在它们前面:

1
2
3
4
lazy var someClosure: (Int, String) -> String = {
[unowned self, weak delegate = self.delegate!] (index: Int, stringToProcess: String) -> String in
// 这里是闭包的函数体
}

如果闭包没有指明参数列表或者返回类型,它们会通过上下文推断,那么可以把捕获列表和关键字 in 放在闭包最开始的地方:

1
2
3
4
lazy var someClosure: () -> String = {
[unowned self, weak delegate = self.delegate!] in
// 这里是闭包的函数体
}

弱引用和无主引用

在闭包和捕获的实例总是互相引用并且总是同时销毁时,将闭包内的捕获定义为 无主引用

相反的,在被捕获的引用可能会变为 nil 时,将闭包内的捕获定义为 弱引用。弱引用总是可选类型,并且当引用的实例被销毁后,弱引用的值会自动置为 nil。这使我们可以在闭包体内检查它们是否存在。

注意

如果被捕获的引用绝对不会变为 nil,应该用无主引用,而不是弱引用。

前面的 HTMLElement 例子中,无主引用是正确的解决循环强引用的方法。这样编写 HTMLElement 类来避免循环强引用:

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 HTMLElement {

let name: String
let text: String?

lazy var asHTML: () -> String = {
[unowned self] in
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}

init(name: String, text: String? = nil) {
self.name = name
self.text = text
}

deinit {
print("\(name) is being deinitialized")
}

}

上面的 HTMLElement 实现和之前的实现一致,除了在 asHTML 闭包中多了一个捕获列表。这里,捕获列表是 [unowned self],表示“将 self 捕获为无主引用而不是强引用”。

和之前一样,我们可以创建并打印 HTMLElement 实例:

1
2
3
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// 打印“<p>hello, world</p>”

使用捕获列表后引用关系如下图所示:

这一次,闭包以无主引用的形式捕获 self,并不会持有 HTMLElement 实例的强引用。如果将 paragraph 赋值为 nilHTMLElement 实例将会被销毁,并能看到它的析构器打印出的消息:

1
2
paragraph = nil
// 打印“p is being deinitialized”

你可以查看 捕获列表 章节,获取更多关于捕获列表的信息。

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau