-
Notifications
You must be signed in to change notification settings - Fork 39
/
14-animations.md.erb
318 lines (204 loc) · 28.5 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
---
title: Анимации
slug: animations
date: 0014/01/01
number: 14
contents: Узнаете что происходит за кулисами когда Meteor меняет местами два DOM элемента.|Научитесь анимировать реорганизацию постов.|Научитесь анимировать добавление новых постов.
paragraphs: 58
---
В нашем приложении уже есть голосование в реальном времени, голосование за посты и рейтинги лучших постов. Из-за такого обилия функционала наши посты прыгают по странице и это не производит хорошего впечатления на пользователей. В этой главе мы научимся добавлять анимацию, чтобы сгладить все перемещения элементов.
### Meteor и DOM
Прежде, чем приступить к самой веселой части (анимировании элементов приложения), нам необходимо понять, как Meteor взаимодействует с DOM (или Document Object Model - набор элементов HTML, представляющие содержимое страниц).
Важный момент, который следует усвоить, заключается в том, что элементы DOM на самом деле не могут быть перемещены; однако их можно удалять и создавать (запомните, что это ограничение DOM, а не Meteor). И чтобы создать иллюзию перемены элементов A и B местами, Meteor будет удалять элемент B и создавать его новую копию перед элементом A.
Из-за этого процесс анимации становится непростым. Мы не можем просто переместить элемент B в его новую позицию, так как B будет удален сразу же, как только Meteor обновит страницу (которая, благодаря реактивности, обновится мгновенно). Но не волнуйтесь, мы найдем решение.
### Советский бегун
Был 1980 год, самый разгар Холодной Войны. Олимпийские игры проходили в Москве, и Советский Союз был полон решимости победить любой ценой в забеге на 100 метров. Для этого группа лучших советских ученых экипировала одного из своих бегунов телепортером, чтобы мгновенно переместить его к финишной черте сразу после выстрела.
К счастью, судьи сразу заметили нарушение, и атлету пришлось телепортироваться обратно на старт, чтобы быть допущенным к участию в гонке, как все остальные.
Мои исторические источники ненадежны, поэтому вам не стоит принимать эту историю за чистую монету. Но попробуйте держать в уме аналогию про "советского бегуна с телепортером", пока читаете эту главу.
### Разбиваем процесс на кусочки
Когда Meteor получит обновление и реактивно изменит DOM, наш пост, словно советский бегун, мгновенно перенесется в его финальное положение. Но ни на Олимпийских играх, ни в нашем приложении мы не можем просто телепортировать все подряд. Поэтому, мы телепортируем элемент назад к стартовой позиции и будем двигать его (другими словами, анимировать) к финишной черте.
Итак, чтобы поменять посты A и B (расположенные в позициях p1 и p2 соответственно), мы совершим следующие действия:
1. Удалим B
2. Создадим B' перед A, в DOM
3. Телепортируем B' в p2
4. Телепортируем A в p1
5. Анимируем A в p2
6. Анимируем B' в p1
Следующая диаграмма объясняет эти шаги в деталях:
<%= diagram "animation_diagram", "Меняем местами два поста", "pull-center" %>
Повторюсь, в шагах 3 и 4 мы не *анимируем* A и B' в их позиции, а мгновенно их “тепепортируем”. Так как это происходит мгновенно, пользователю может показаться что они никогда не были удалены, и корректные положения обоих элементов анимируются вперед к их новым значениям.
К счастью, Meteor берет на себя заботу о двух первых шагах, так что нам нужно подумать только о шагах с 3 по 6.
Более того, в шагах 5 и 6 все что нам нужно сделать это передвинуть элементы в их должное положение. Таким образом, в действительности, нам нужно подумать только о пунктах 3 и 4, т.е., отправить элементы в их начальную позицию анимации.
### Правильный тайминг
До этого момента мы говорили о том, *как* анимировать наши посты, но не *когда* анимировать их.
Для шагов 3 и 4 ответ будет следующим - всякий раз, когда изменяется свойство поста '_rank' (от которого и зависит позиция поста в списке).
Шаги 5 и 6 будут хитрее. Представьте следующую вещь: если вы сообщите абсолютно логичному андроиду бежать на север 5 минут, а затем бежать 5 минут на на юг, он скорее всего сообразит, что если он должен закончить пробежку на стартовом месте, будет проще сохранить энергию и не бежать вовсе.
И если вы хотите убедится, что ваш андроид бежал 10 минут, вам придется *подождать*, пока он не пробежит первые 5 минут, и *затем* приказать ему бежать обратно.
Браузер работает похожим образом: если мы просто дадим обе инструкции одновременно, то старые координаты будут переписаны новыми и ничего не случится. Другими словами, браузер должен регистрировать изменение позиций как отдельные точки во времени, иначе он не сможет их анимировать.
Meteor не предоставляет встроенного коллбека на этот случай, но мы можем имитировать его, используя `Meteor.setTimeout()`, который просто берет функцию и откладывает ее выполнение на несколько миллисекунд.
### Позиционирование элементов с помощью CSS
Чтобы анимировать реорганизацию постов в списке, нам следует вторгнуться на территорию CSS. Давайте быстро повторим основные правила, как элементы позиционируются на странице с помощью CSS.
Элементы страницы по умолчанию используют **статичное** - **static** позиционирование. Статически размещенный элемент просто располагается в потоке на странице, и его экранные координаты не могут быть изменены или анимированны.
**Relative** - **относительное** позиционирование в свою очередь подразумевает, что элемент позиционируется традиционным способом на странице, но может быть также сдвинут *относительно своего изначального положения*.
**Absolute** - **абсолютное** позиционирование делает еще один шаг вперед и позволяет вам задать конкретные координаты x/y относительно **корневого документа** или **первого абсолютно или относительно позиционированного элемента-родителя**.
Мы будем использовать относительное позиционирование для анимации наших постов. Мы уже позаботились о CSS для вас, но если вам хочется добавить стили самостоятельно, просто добавьте этот код в ваш CSS файл:
~~~css
.post{
position:relative;
transition:all 300ms 0ms ease-in;
}
~~~
<%= caption "client/stylesheets/style.css" %>
Шаги 5-6 будут совсем простыми: вам нужно только сбросить значение `top` на `0px` (это его значение по-умолчанию), и наши посты плавно сдвинутся обратно к их "нормальной" позиции.
Другими словами, наша задача - понять, *откуда* анимировать посты (шаги 3 и 4). То есть, на сколько пикселей нужно сдвинуть посты. Но это совсем несложно: правильный сдвиг можно вычислить, отняв координаты новой позиции поста от предыдущей.
<% note do %>
### Position:absolute
Мы могли бы использовать `position:absolute` вместе с относительным родительским элементом для позиционирования наших постов. Но у абсолютного позиционирования есть один большой минус - они совершенно выпадают из структуры страницы, заставляя родительский элемент сжаться, словно внутри ничего нет.
Чтобы компенсировать это, нам придется воспользоваться арсеналом JavaScript, искуственно выставив размеры родительского контейнера - вместо того, чтобы дать браузеру возможность позиционировать элементы естественным образом. Так что лучше использовать относительное позиционирование.
<% end %>
### Вспомнить все
Есть еще одна загвоздка. В то время как элемент А остается в DOM и, таким образом, "помнит" свою предыдущую позицию, элемент B переживает реинкарнацию и возвращается к жизни как новый элемент B', с абсолютно стертой памятью.
Чтобы решить эту задачу, мы воспользуемся **локальной коллекцией**. В нее мы сохраним текущую позицию поста на странице. Локальная коллекция работает точно так же, как и обычная коллекция Meteor, за исключением того, что она остается *только* в памяти браузера (не отправляет данные на сервер). Таким образом, даже если пост удален и воссоздан заново, мы все еще сможем понять, с какого места на странице его надо анимировать.
### Рейтинг постов
Мы много говорили про рейтинг постов, но этот "рейтинг" не существует как отдельное свойство поста, а является всего лишь следствием того, в каком порядке посты сохранены в нашей коллекции. Если мы хотим анимировать посты согласно их рейтингу, нам надо наколдовать это свойство из воздуха.
Обратите внимание: мы не можем просто добавить свойство `rank` в базу данных, так как рейтинг это относительное свойство, которое просто зависит от того, как мы сортируем посты (то есть, отдельно взятый пост может иметь первое место при сортировке по дате, но третье место при сортировке по количеству голосов).
В идеале, мы хотели бы добавить это свойство в коллекции `newPosts` и `topPosts`, но Meteor на данный момент не предлагает подходящего механизма для этого.
Вместо этого мы добавим свойство `rank` на самом последнем шаге, в менеджере шаблона `postList`:
~~~js
Template.postsList.helpers({
postsWithRank: function() {
this.posts.rewind();
return this.posts.map(function(post, index, cursor) {
post._rank = index;
return post;
});
}
});
~~~
<%= caption "/client/views/posts/posts_list.js" %>
<%= highlight "2~8" %>
Вместо того чтобы просто вернуть курсор `Posts.find({}, {sort: {submitted: -1}, limit: postsHandle.limit()})` как наш предыдущий хелпер `posts`, `postsWithRank` принимает курсор и добавляет свойство `_rank` к каждому из его документов.
Не забудьте обновить шаблон `postsList`:
~~~html
<template name="postsList">
<div class="posts">
{{#each postsWithRank}}
{{> postItem}}
{{/each}}
{{#if nextPath}}
<a class="load-more" href="{{nextPath}}">Load more</a>
{{/if}}
</div>
</template>
~~~
<%= caption "/client/views/posts/posts_list.html" %>
<%= highlight "3" %>
<% note do %>
### Пожалуйста, перемотайте
Meteor является одной из самых передовых сред разработки веб-приложений. Но одна из его особенностей как будто пришла со времен видеомагнитофонов и записи на видеокассеты. Мы говорим о функции `rewind()`.
Каждый раз, когда вы вызываете функции `forEach()`, `map()`, или `fetch()` для курсора, вам нужно перемотать курсор на место перед тем, как им можно воспользоваться снова.
Поэтому, на всякий случай, курсор стоит перематывать каждый раз и не рисковать возможностью ошибиться.
<% end %>
### Собираем все вместе
Так как наша анимация будет влиять на CSS атрибуты и классы нашего DOM элемента, мы добавим динамический хелпер `{{attributes}}` нашему шаблону `postItem`:
```html
<template name="postItem">
<div class="post" {{attributes}}>
//..
</template>
```
<%= caption "/client/views/posts/post_item.html" %>
<%= highlight "2" %>
Используя хелпер `{{attributes}}`, мы также открываем скрытое свойство Spacebars - любое свойство возвращаемого объекта `attributes` будет автоматически соотнесено с HTML атрибутами DOM элемента (такими как `class`, `style`, и так далее).
Давайте соберем все вместе, создав хелпер `attributes`:
~~~js
var POST_HEIGHT = 80;
var Positions = new Meteor.Collection(null);
Template.postItem.helpers({
//..
attributes: function() {
var post = _.extend({}, Positions.findOne({postId: this._id}), this);
var newPosition = post._rank * POST_HEIGHT;
var attributes = {};
if (! _.isUndefined(post.position)) {
var offset = post.position - newPosition;
attributes.style = "top: " + offset + "px";
if (offset === 0)
attributes.class = "post animate"
}
Meteor.setTimeout(function() {
Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
});
return attributes;
}
});
//..
~~~
<%= caption "/client/views/posts/post_item.js" %>
<%= highlight "1~2, 8~25" %>
В начале нашего документа мы устанавливаем значение `height` - высоту для каждого DOM элемента - то есть, наших `div` элементов с постами. Если что-то повлияет на данное значение `height`, например, длинный текст заголовка поста займет больше одной строки, наша анимация сломается. Но на данный момент, чтобы слегка упростить вещи, мы предположим, что каждый пост будет ровно 80 пикселей в высоту.
Далее мы объявляем локальную коллекцию под названием `Positions`. Обратите внимание как мы передаем `null` в качестве аргумента - это дает Meteor знать что мы создаем именно локальную коллекцию (только для клиента).
Все готово, чтобы создать наш хелпер `attributes`.
<% note do %>
### Расписание движения
Иногда бывает непросто понять, когда кусочек реактивного кода будет запущен. Давайте подробнее взглянем на хелпер `attributes`.
Как любой хелпер, он будет запущен, когда шаблон будет отрисован. Из-за его зависимости к атрибуту `_rank` он также будет перезапущен каждый раз, когда рейтинг поста изменится. И, наконец, его зависимость от коллекции `Positions` означает, что он будет перезапущен, когда данный объект будет отредактирован.
Следовательно, хелпер может быть запущен два или три раза подряд. Такое положение вещей может прозвучать расточительным, но это именно то, как реактивность работает. Как только вы привыкнете к ней, это станет неотъемлемой частью разработки приложения, подходом к написанию кода.
<% end %>
### Хелпер Attributes
Для начала мы найдем позицию поста в коллекции `Positions` и расширим `this` (который, в данном случае, соотносится с текущим постом) результатом нашего запроса. Мы воспользуемся атрибутом `_rank` для расчета новых координат DOM элемента относительно начала страницы.
Затем мы должны позаботиться о двух сценариях - либо хелпер запущен потому, что шаблон отрисовывается (А), или он запущен реактивно, потому что изменено значение атрибута (B).
Нам нужно анимировать элемент только в случае B, поэтому для начала мы убедимся, что свойство `post.position` существует и имеет значение (*как именно* оно было создано, мы скоро узнаем).
Вдобавок ко всему, сценарий В имеет два возможных варианта: В1 и В2; либо мы *телепортируем* наш элемент DOM назад на стартовую позицию (его предыдущую позицию), либо мы *анимируем* его с предыдущей позиции на новую.
Тут-то и стоит воспользоваться переменной `offset`. Так как мы используем *относительное* - *relative* позиционирование, нужно рассчитать новые координаты *относительно* текущей позиции. Этого можно достичь вычитанием новой позиции из предыдущей.
Чтобы отличить случай В1 от В2, мы просто проверим на значение свойство `offset`: если `offset` отличается от 0, это означает что мы *отодвигаем* элемент от его первоначального положения, и мы можем добавить класс `animate` к этому элементу, чтобы его перемещение было анимировано с помощью волшебства CSS transition.
### Работа с функцией setTimeout
Эти три ситуации (А, В1 и В2) запускаются реактивным способом, когда значение определенных атрибутов меняется. В этом случае, функция `setTimeout` запускает переопределение реактивного контекста, изменяя коллекцию `Positions`.
Поэтому, когда пользователь впервые загружает страницу, весь реактивный процесс происходит следующим образом:
- Хелпер `attributes` запускается в первый раз.
- Значение `post.position` не определено **(A)**.
- `setTimeout` запускается и определяет значение `post.position`.
- Хелпер `attributes` реактивно перезапускается.
- Перемещения поста не произошло, поэтому значение параметра `offset` меняется с 0 на 0 (анимация не происходит) **(В2)**.
А вот что происходит, когда пользователь голосует за пост:
- Значение `_rank` меняется, что запускает переопределение хелпера `attributes`.
- Значение `post.position` определено **(B)**.
- Значение `offset` не равно 0, значит анимации нет **(B1)**.
- Запускается `setTimeout`, который переопределяет значение `post.position`.
- Хелпер `attributes` реактивно перезапускается.
- Значение `offset` меняется назад на 0 (с анимацией) **(B2)**.
Откройте сайт и попробуйте проголосовать за несколько постов. Вы должны увидеть, как посты с плавной грацией перемещаются вверх и вниз.
<%= commit "14-1", "Добавили анимацию к реорганизации списка постов." %>
### Анимация новых постов
Наши посты правильно реорганизуются, но у нас все еще отсутствует анимация для новых постов. Вместо того, чтобы скучно добавлять посты поверх списка, давайте добавим эффект плавного появления.
~~~js
//..
attributes: function() {
var post = _.extend({}, Positions.findOne({postId: this._id}), this);
var newPosition = post._rank * POST_HEIGHT;
var attributes = {};
if (_.isUndefined(post.position)) {
attributes.class = 'post invisible';
} else {
var delta = post.position - newPosition;
attributes.style = "top: " + delta + "px";
if (delta === 0)
attributes.class = "post animate"
}
Meteor.setTimeout(function() {
Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
});
return attributes;
}
//..
~~~
<%= caption "/client/views/posts/post_item.js" %>
<%= highlight "8~10" %>
Мы изолируем сценарий **(А)** и добавляем нашему элементу CSS класс `invisible`. Когда хелпер реактивно перезапускается, и элемент получает класс `animate`, разница в значениях прозрачности (opacity) будет анимирована, и элемент плавно проявится на странице.
<%= commit "14-2", "Плавное проявление постов." %>
<% note do %>
### CSS & JavaScript
Вы наверное обратили внимание, что мы используем CSS класс `.invisible` для запуска анимации вместо того, чтобы анимировать CSS параметр `opacity` напрямую, как мы это делали с параметром `top`. Это все потому, что нам нужно было анимировать `top` до определенного значения, которое зависело от данных конкретного поста.
С другой стороны, в данном случае мы только хотим показать или спрятать элемент вне зависимости от его содержимого. Хорошей практикой обычно является держать стили CSS и логику JavaScript отдельно друг от друга, поэтому здесь мы будем только добавлять и удалять класс элемента, а саму анимацию определим в стилях CSS.
<% end %>
Наша анимация должна работать так, как и задумывалась с самого начала. Загрузите приложение и попробуйте! Также вы можете поиграть с классами `.post.animated` и попробовать разные эффекты перехода - transitions. Подсказка: [CSS функции с разнообразными кривыми анимации](http://matthewlein.com/ceaser/) отличное место для старта!