-
Notifications
You must be signed in to change notification settings - Fork 4
/
14-animations.md.erb
357 lines (257 loc) · 20.4 KB
/
14-animations.md.erb
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
---
title: Animations
slug: animations
date: 0014/01/01
number: 14
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/8377615133/
photoAuthor: Mike Lewinski
contents: Tìm hiểu điều gì diễn ra phía sau màn hình khi mà Meteor hoán đổi hai thành phần DOM.|Học cách tạo animation cho thành phần sắp xếp lại bài viết.|Học cách tạo animation cho thao tác chèn và xoá bài viết.|Học về animation khi di chuyển giữa hai trang.
paragraphs: 58
---
Bây giờ chúng ta đã có tính năng bỏ phiếu theo thời gian thực, ghi điểm, và xếp hạng. Tuy nhiên, điều này dẫn đến sự xung đột, không ổn định đối với trải nghiệm người dùng khi mà bài viết nhảy chỗ này qua chỗ khác trên trang chủ. Chúng ta sẽ dùng animation để làm trơn vấn đề này.
### Giới thiệu `_uihooks`
`_uihooks` là một tính năng còn mới và chưa được tạo tài liệu của Blaze. Như tên gọi, nó cho phép chúng ta truy cập tới hàm hook mà có thể kích hoạt mỗi khi có thành phần mới được chèn vào, xoá bỏ hoặc khi tạo animation.
Danh sách đầy đủ hàm hook như bên dưới:
- `insertElement`: được gọi mỗi khi có thành phần mới được chèn vào.
- `moveElement`: được gọi mỗi khi có thành phần thay đổi vị trí.
- `removeElement`: được gọi khi một thành phần bị xoá.
Một khi đã được định nghĩa, những hàm hook này sẽ *thay thế* hành vi mặc định của Meteor. Hay nói theo cách khác, thay vì việc chèn mới, di chuyển hoặc xoá phần tử, Meteor sẽ làm bất kỳ thứ gì mà chúng ta mô tả - và chúng ta hoàn toàn có thể điều khiển những hoạt động này như ý muốn!
### Meteor & DOM
Trước khi bắt đầu vào phần thú vị (là làm cho mọi thứ di chuyển), chúng ta cần phải hiểu cách Meteor tương tác với DOM (Document Object Model -- tổ hợp những thành phần HTML làm nên nội dung của trang).
Điểm cốt yếu phải chú ý là thành phần DOM thực sự không có khả năng “di chuyển”; tuy nhiên, chúng có thể được xoá và tạo lại (chú ý rằng đây là hạn chế của chính bản thân DOM, không phải của Meteor). Vì vậy để tạo cảm giác phần tử A và B thay đổi vị trí, Meteor sẽ thực sự xoá thành phần B và chèn một thành phần sao chép mới (B') trước thành phần A.
Điều này làm cho animation cần một chút thủ thuật, do chúng ta không thể tạo animation cho B di chuyển tới vị trí mới, vì B sẽ biến mất ngay khi Meteor tạo lại trang (điều này xảy ra ngay lập tức vì khả năng phản ứng lại của Meteor). Xin đừng lo lắng, chúng ta sẽ tìm ra cách.
### Vận động viên chạy Soviet
Nhưng trước hết, hãy bắt đầu với một câu chuyện.
Đó là vào năm 1980, trong giai đoạn của chiến tranh lạnh. Olympics được tổ chức tại Moscow, và người Soviet quyết định phải chiến thắng giải chạy 100 mét bằng mọi giá. Vì vậy một nhóm nhà khoa học thông minh Soviet trang bị cho vận động viên với thiết bị di chuyển tức thời, ngay khi nghe thấy tiếng súng bắt đầu, vận động viên biến mất chớp nhoáng, và ngay lập tức hiện ra lại trong không-thời gian liên tục tại điểm kết thúc.
May mắn là giám khảo cuộc đua đã sớm nhận ra sự vi phạm, và vận động viên đó không còn cách nào khác là di chuyển tức thời về lại vạch xuất phát, trước khi được phép tham gia cuộc đua như mọi vận động viên khác.
Nguồn gốc của câu chuyện lịch sử này có thể không thực sự đáng tin cậy, vì vậy bạn không nên tin nó hoàn toàn đúng. Nhưng hãy giữ lại "người vận động viên Soviet và thiết bị di chuyển tức thời" trong đầu khi đi tiếp nội dung chương này.
### Chia nhỏ
Khi Meteor nhận được cập nhật và phản ánh lại vào DOM, bài viết của chúng ta sẽ được dịch chuyển tức thời tới vị trí cuối cùng của chúng, và giống như là vận động viên soviet. Nhưng dù cho trong trường hợp Olympics hay trong ứng dụng của chúng ta, việc di chuyển tức thời là không thể. Vì vậy, chúng ta di chuyển thành phần về “vị trí xuất phát” và làm cho nó “chạy” (hay nói cách khác, tạo animation cho nó) tới vị trí đích.
Bởi vậy để thay đổi vị trí bài viết A và B (đang được đặt tại vị trí p1 và p2 theo thứ tự), chúng ta sẽ đi theo các bước sau:
1. Xoá B
2. Tạo B' phía trước A trong DOM
3. Di chuyển tức thời B' tới p2
4. Di chuyển tức thời A tới p1
5. Tạo animation A tới p2
6. Tạo animation B' tới p1
Biểu đồ sau giải thích những bước trên chi tiết hơn:
<%= diagram "animation_diagram", "Switching two posts", "pull-center" %>
Một lần nữa, trong bước 3 và 4 chúng ta đã không *tạo animation* giữa A và B' tới vị trí của chúng mà “di chuyển tức thời” chúng. Vì điều này xảy ra ngay lập tức, chúng sẽ có hiệu ứng là B đã không bị xoá, và cả hai thành phần đều được chuyển tới vị trí mới.
Mặc định, Meteor có thể giải quyết được bước 1 & 2, và việc thực hiện lại chúng khá là dễ dàng. Và ở bước 5 và 6, tất cả việc chúng ta phải làm là di chuyển thành phần tới vị trí mới. Do vậy, phần mà chúng ta thực sự cần phải quan tâm là bước 3 và 4, gửi các thành phần tới điểm bắt đầu animation.
### Thay đổi vị trí với CSS
Để tạo animation cho bài viết cần sắp xếp, chúng ta sẽ phải dấn thân vào mảnh đất CSS. Một chút ôn lại về thay đổi vị trí với CSS có thể sẽ cần thiết.
Thành phần trên trang web mặc định dùng vị trí *tĩnh*. Thành phần được đặt vị trí tĩnh phù hợp với luồng của trang, và toạ độ của chúng trên màn hình không thể thay đổi hoặc tạo animation.
Vị trí **relative** (tương đối) thì khác, chúng là thành phần phù hợp với luồng của trang, nhưng đồng thời cũng có thể thay đổi vị trí *tương đối so với vị trí ban đầu*.
Vị trí **absolute** (tuyệt đối) đi một bước xa hơn và để cho bạn thiết toạ độ x/y tương đối so với **tài liệu** hoặc **thành phần cha tuyệt đối hoặc tương đối đầu tiên**.
Chúng ta sẽ dùng vị trí tương đối để tạo animation cho bài viết. Chúng tôi đã tạo CSS cho bạn, nhưng nếu bạn muốn tự làm thì tất cả mọi việc cần thực hiện là thêm đoạn code sau vào stylesheet:
~~~css
.post{
position:relative;
transition:all 300ms 0ms ease-in;
}
~~~
<%= caption "client/stylesheets/style.css" %>
Điều này làm cho bước 5 và 6 dễ dàng hơn: tất cả mọi việc chúng ta cần làm là thiết lập lại `top` thành `0px` (giá trị mặc định) và bài viết của chúng ta sẽ di chuyển trở lại vị trí "bình thường".
Vì vậy về cơ bản, thử thách duy nhất của chúng ta là tìm ra nơi để *từ đó* tạo ra animation (bước 3 và 4) tương đối tới vị trí mới. Nói cách khác, phải bù trừ bao nhiêu. Nhưng điều đó cũng không thực sự khó: cách đơn giản là lấy vị trí của một bài viết cũ trừ đi vị trí mới của nó.
### Cài đặt `_uihooks`
Bây giờ chúng ta đã hiều về những nhân tố khác nhau khi thử với việc tạo animation cho một danh sách hạng mục. Chúng ta thực sự đã sẵn sàng để cài đặt animation. Chúng ta sẽ bắt bằng việc bọc lại danh sách bài viết vào một thành phần chứa đựng `.wrapper`:
```html
<template name="postsList">
<div class="posts page">
<div class="wrapper">
{{#each posts}}
{{> postItem}}
{{/each}}
</div>
{{#if nextPath}}
<a class="load-more" href="{{nextPath}}">Load more</a>
{{else}}
{{#unless ready}}
{{> spinner}}
{{/unless}}
{{/if}}
</div>
</template>
```
<%= caption "/client/templates/posts/post_list.html" %>
<%= highlight "3,7" %>
Trước khi chúng ta làm bất kỳ thứ gì khác, hãy cùng nhìn lại hoạt động của bài viết hiện tại, *mà không có* animation:
<%= gifscreenshot "14-1", "The non-animated post list." %>
Hãy cùng mang đến `_uihooks`. Chúng ta sẽ chọn div `.wrapper` đó bên trong template callback `rendered`, và định nghĩa một thành phần hook `moveElement`.
```js
Template.postsList.rendered = function () {
this.find('.wrapper')._uihooks = {
moveElement: function (node, next) {
// do nothing for now
}
}
}
```
<%= caption "/client/templates/posts/post_list.js" %>
<%= highlight "1~7" %>
Hàm `moveElement`mà chúng ta vừa định nghĩa sẽ được gọi mỗi khi vị trí của một thành phần mới thay đổi *thay vì* hoạt động mặc định của Blaze. Và do hàm đó đang rỗng, điều đó nghĩa là *không có gì diễn ra*.
Hãy thử nó: mở phần hiển thị “Best“ và upvote một vài bài viết: thứ tự sẽ không bị thay đổi cho tới khi bạn bắt buộc nó dịch lại trang (bằng việc tải lại trang hoặc thay đổi route).
<%= gifscreenshot "14-2", "An empty moveElement callback: nothing happens" %>
Chúng ta vừa kiểm chứng rằng `_uihooks` hoạt động. Bây giờ hãy tạo animation cho nó!
### Tạo animation cho việc sắp xếp lại bài viết
Hàm hook `moveElement` nhận vào hai tham số: `node` và `next`.
- `node` là thành phần đang được di chuyển tới vị trí mới trong DOM.
- `next` là thành phần ngay *sau* vị trí mới mà `node` đang được di chuyển tới.
Biết được điều này, chúng ta có thể tạo ra quá trình animation như sau (hãy tham chiếu lại phần ví dụ về “vận động viên Soviet” nếu cần). Khi mà một sự thay đổi được tìm ra, chúng ta sẽ:
1. Chèn `node` vào trước `next` (nói cách khác, hoạt động mặc định sẽ diễn ra nếu chúng ta không đặc tả hàm hook `moveElement` nào).
2. Di chuyển `node` quay trở lại vị trí ban đầu của nó.
3. Nhích mọi thành phần giữa `node` và `next` để tạo không gian cho `node`.
4. Tạo animation cho tất cả thành phần trở lại ví trí mặc định mới.
Chúng ta sẽ làm tất cả điều này thông qua thư viện [jQuery](http://jquery.com), là thư viện xử lý DOM tốt nhất hiện nay. jQuery thực ra nằm ngoài phạm vi của cuốn sách, nhưng hãy cùng lướt qua một số method jQuery hữu ích mà chúng ta sẽ sử dụng:
- [`$()`](http://api.jquery.com/jQuery/): bọc bất kỳ thành phần DOM nào thành một object jQuery.
- [`offset()`](http://api.jquery.com/offset/): gọi ra vị trí hiện tại của một thành phần tương đối với *tài liệu*, và trả về một object chứa thuộc tính `top` và `left`.
- [`outerHeight()`](http://api.jquery.com/outerHeight/): lấy ra độ dài “bên ngoài” (bao gồm padding và margin) của một thành phần.
- [`nextUntil(selector)`](http://api.jquery.com/nextUntil/): lấy tất cả phần tử phía sau phần tử mục tiêu cho tới (nhưng không bao gồm) phần tử match với `selector`.
- [`insertBefore(selector)`](http://api.jquery.com/insertBefore/): chèn một thành phần trước thành phần match với `selector`.
- [`removeClass(class)`](http://api.jquery.com/removeClass/): xoá bỏ class CSS `class` nếu xuất hiện trên thành phần.
- [`css(propertyName, propertyValue)`](http://api.jquery.com/css/): thiết lập thuộc tính CSS `propertyName` thành `propertyValue`.
- [`height()`](http://api.jquery.com/height/): lấy độ cao của một thành phần.
- [`addClass(class)`](http://api.jquery.com/addClass/): thêm class CSS `class` CSS vào một thành phần.
```js
Template.postsList.rendered = function () {
this.find('.wrapper')._uihooks = {
moveElement: function (node, next) {
var $node = $(node), $next = $(next);
var oldTop = $node.offset().top;
var height = $node.outerHeight(true);
// find all the elements between next and node
var $inBetween = $next.nextUntil(node);
if ($inBetween.length === 0)
$inBetween = $node.nextUntil(next);
// now put node in place
$node.insertBefore(next);
// measure new top
var newTop = $node.offset().top;
// move node *back* to where it was before
$node
.removeClass('animate')
.css('top', oldTop - newTop);
// push every other element down (or up) to put them back
$inBetween
.removeClass('animate')
.css('top', oldTop < newTop ? height : -1 * height)
// force a redraw
$node.offset();
// reset everything to 0, animated
$node.addClass('animate').css('top', 0);
$inBetween.addClass('animate').css('top', 0);
}
}
}
```
<%= caption "/client/templates/posts/post_list.js" %>
Một vài ghi chú:
- Chúng ta tính toán độ dài của `$node` để biết xem phải bù vào thành phần `$inBetween` bao nhiêu. Chúng ta dùng `outerHeight(true)` để có margin và padding làm nhân tố trong việc tính toán.
- Chúng ta không biết là `next` tới trước hay là sau `node` khi đi dần xuống DOM. Vì vậy chúng ta kiểm tra cả hai cấu hình khi định nghĩa `$inBetween`.
- Để chuyển giữa thành phần “di chuyển tức thời” and “animation”, chúng ta đơn giản dịch chuyển CSS class `animate` tắt và mở (animation thực sự được định nghĩa trong code CSS ứng dụng).
- Vì chúng ta đang sử dụng vị trí tương đối, chúng ta có thể luôn luôn điều chỉnh lại bất kỳ thành phần thuộc tính `top` nào trở về 0 để đưa lại vị trí mà thuộc về.
<% note do %>
### Bắt buộc vẽ lại
Bạn có thể đang thắc mắc về dòng `$node.offset()`. Tại sao chúng ta lại hỏi vị trí của `$node` nếu như chúng ta không định làm gì đó với nó?
Hãy theo cách này: nếu như bạn bảo một thiết bị người máy thông minh chạy về hướng bắc 5 km, và sau khi đã hoàn thành thì chạy lại vị trí ban đầu, nó có thể sẽ suy ra rằng việc kết thúc tại cùng vị trí có thể giảm chi phí năng lượng và không chạy đi đâu cả.
Vì vậy để chắc chắn rằng thiết bị người máy của chúng ta chạy tổng cộng 10km, chúng ta sẽ bảo nó đo lại toạ độ tại thời điểm 5km trước khi quay đầu về.
Trình duyệt cũng hoạt động theo cách tương tự: nếu chúng ta đưa ra cả lệnh `css('top', oldTop - newTop)` và `css('top', 0)` một cách liên tục, toạ độ mới sẽ đơn giản thay thế toạ độ cũ và không có gì xảy ra. Nếu chúng ta thực sự muốn thấy animation, chúng ta cần phải bắt buộc trình duyệt vẽ lại thành phần sau khi vị trí đầu đã thay đổi.
Một cách đơn giản để bắt buộc vẽ lại là bảo trình duyệt kiểm tra thành phần `offset` -- nó không thể biết đó là gì cho đến khi đã vẽ lại thành phần một lần nữa.
<% end %>
Hãy tạo ra sự quay vòng. Hãy quay trở lại phần hiển thị “Best” và bắt đầu upvoting: bạn sẽ thấy bài viết lướt lên và xuống với vẻ duyên dáng như đang múa ba lê!
<%= gifscreenshot "14-3", "Animated reordering" %>
<%= commit "14-1", "Added post reordering animation." %>
### Không thể làm mờ dần
Bây giờ khi chúng ta đã hoàn thành phần thủ thuật cho việc sắp xếp, phần tiếp theo sẽ về bài viết với animation được chèn vào và xoá đi!
Đầu tiên, chúng ta sẽ fade in bài viết mới (chú ý rằng vì mục đích đơn giản, chúng ta sử dụng JavaScript animation trong lần này):
```js
Template.postsList.rendered = function () {
this.find('.wrapper')._uihooks = {
insertElement: function (node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
moveElement: function (node, next) {
//...
}
}
}
```
<%= caption "/client/templates/posts/post_list.js" %>
<%= highlight "3~7" %>
Để có một bức tranh rõ ràng, chúng ta có thể kiểm tra animation mới bằng việc chèn bài viết thông qua dòng lệnh với:
```js
Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
```
<%= gifscreenshot "14-4", "Fading in new posts" %>
Và sau đó chúng ta sẽ fade out bài viết đã xoá:
```js
Template.postsList.rendered = function () {
this.find('.wrapper')._uihooks = {
insertElement: function (node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
moveElement: function (node, next) {
//...
},
removeElement: function(node) {
$(node).fadeOut(function() {
$(this).remove();
});
}
}
}
```
<%= caption "/client/templates/posts/post_list.js" %>
<%= highlight "12~16" %>
Một lần nữa, hãy đơn giản xoá bài viết thông qua dòng lệnh (dùng `Posts.remove('somePostId')`) để thấy hiện ứng diễn ra.
<%= gifscreenshot "14-5", "Fading out deleted posts" %>
<%= commit "14-2", "Fade items in when they are drawn." %>
### Chuyển tiếp trang
Cho đến giờ chúng ta đã tạo thành phần animation *bên trong* một trang. Nhưng điều gì nếu như chúng ta muốn thêm hiệu ứng chuyển tiếp *giữa các* trang?
Việc chuyển tiếp trang là công việc của Iron Router. Bạn bấm vào một đường dẫn, và nội dung của helper `{{> yield}}` trong `layout.html` được tự động thay đổi.
Nó giống như chúng ta đã thay đổi hoạt động mặc định của Blaze cho danh sách bài viết, chúng ta có thể làm điều tương tự cho `{{> yield}}` để thêm vào hiệu ứng fade giữa các route!
Nếu chúng ta muốn fade in và out trang web, chúng ta sẽ phải chắc chắn rằng trang này hiển thị trên chóp của trang khác. Chúng ta làm điều đó bằng việc dùng `position:absolute` trên div container`.page` mà bọc mọi template của trang.
Chúng ta không muốn trang hoàn toàn được đặt vị trí tương đối với cửa sổ window, vì nó sẽ che mất header của ứng dụng. Vì vậy chúng ta sẽ để `position:relative` cho div `#main` để `position:absolute` của div `.page` nhận nguồn từ `#main`.
Để tiết kiệm thời gian, chúng tôi đã tạo sẵn code CSS cần thiết cho `style.css`:
```css
//...
#main{
position: relative;
}
.page{
position: absolute;
top: 0px;
width: 100%;
}
//...
```
<%= caption "/client/stylesheets/style.css" %>
Đã đến lúc để thêm vào code fade cho trang. Nó cũng khá quen thuộc, vì nó là đoạn code mà chúng ta đã dùng cho việc chèn và xoá bài viết:
```js
Template.layout.rendered = function() {
this.find('#main')._uihooks = {
insertElement: function(node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
removeElement: function(node) {
$(node).fadeOut(function() {
$(this).remove();
});
}
}
}
```
<%= caption "/client/templates/application/layout.js" %>
<%= gifscreenshot "14-6", "Transitioning in-between pages with a fade" %>
<%= commit "14-3", "Transition between pages by fading." %>
Chúng ta vừa xem xét một vài mô hình cho việc tạo animation cho thành phần trong ứng dụng Meteor. Trong khi đây không phải là một danh sách xem xét hết mọi khía cạnh, hi vọng rằng nó cung cấp phần cơ bản để xây dựng sự chuyển tiếp chi tiết hơn.