Итак, во второй части мы приступаем к реализации. Для начала определимся с технологией. Я выбираю веб-компоненты. Компонентный подход, нативное апи, легко переиспользовать и дебажить.
Тег для нашего виртуального вьюпорта будет называться custom-viewport. Итак, сперва опишем общие свойства для вьюпорта:
Позиция inited:
Позиция opened:
Позиция deleted:
Реализуем события dragUp/dragDown
Схематично код выше можно описать так
Итак, теперь мы умеем различать события dragUp/dragDown. Следующая утилита — расчет запаса хода.
Здесь мы сначала запоминаем сколько у нас было запаса хода на момент начала движения, а потом просто добавляем к этому значению deltaY и смотрим: можем мы двигаться вверх или нет.
Собственно логика dragUp:
Пишем метод, который будет перемещать вьюпорт:
Разберем поподробнее что такое this.lastPosY и как он высчитывается. Если в css мы писали transform: translateY(calc(100% — 50vh)); где 100% — это высота самого виртуального вьюпорта, а 50vh — это половина высоты реального вьюпорта, и для статического описания положения это подходит хорошо, то для вычисления перемещения в динамике удобнее оперировать абсолютными величинами, именно эти преобразования мы здесь производим.
Итак this.lastPosY — это величина перемещения виртуального вьюпорта в пикселях на момент начала движения, именно к нему мы добавляем this.deltaY и получаем новое положение вьюпорта.
Так как мы определили свойства:
то наша система координат для отсчета передвижения вьюпорта примет вид
Опишем dragDown:
Собственно событие dragEnd:
В строчке if (Math.abs(deltaY) < 10) мы указываем что если передвинулись меньше чем на 10 пикселей — оставить текущее положение.
Данный код не является законченной реализацией, а лишь прототипом. Более детальная проработка скрола, дебоунсы, еще какие-либо оптимизации, touchcancel — оставлены на откуп читателю.
Шаг 1 — описать конечные положения
Тег для нашего виртуального вьюпорта будет называться custom-viewport. Итак, сперва опишем общие свойства для вьюпорта:
custom-viewport {
min-height: 50vh;
max-height: 100vh;
width: 100%;
position: absolute;
bottom: 0;
overflow: hidden;
transform-origin: 50% 100% 0;
}
Позиция inited:
custom-viewport[data-mode = "inited"] {
transform: translateY(calc(100% - 50vh));
transition: transform 1s;
}
Позиция opened:
custom-viewport[data-mode = "opened"] {
transform: translateY(0);
transition: transform 1s;
overflow-y: scroll;
}
Позиция deleted:
custom-viewport[data-mode = "deleted"] {
transform: translateY(100%);
transition: transform 1s;
}
Шаг 2 — начинаем писать компонент custom-viewport
class CustomViewport extends HTMLElement {
constructor() {
super();
}
}
Реализуем события dragUp/dragDown
class CustomViewport extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.addEventListener("touchstart", ev => {
this.firstTouch = ev.touches[0];
});
this.addEventListener("touchmove", ev => {
this.deltaY = ev.touches[0].clientY - this.firstTouch.clientY;
return this.deltaY > 0 ? this.dragDown(ev) : this.dragUp(ev);
});
}
dragUp(ev) {}
dragDown(ev) {}
}
Схематично код выше можно описать так
Итак, теперь мы умеем различать события dragUp/dragDown. Следующая утилита — расчет запаса хода.
class CustomViewport extends HTMLElement {
constructor() {
super();
this.VIEWPORT_HEIGHT = window.innerHeight; // +
}
connectedCallback() {
this.addEventListener("touchstart", ev => {
this.firstTouch = ev.touches[0];
const rect = this.getBoundingClientRect(); // +
const { height, top } = rect; // +
this.bottomOffsetBeforeDragging = (height + top) - this.VIEWPORT_HEIGHT; // +
});
this.addEventListener("touchmove", ev => {
this.deltaY = ev.touches[0].clientY - this.firstTouch.clientY;
return this.deltaY > 0 ? this.dragDown() : this.dragUp();
});
}
dragUp() {}
dragDown() {}
isBottomOffset() { // +
return (this.bottomOffsetBeforeDragging + this.deltaY) > 0; // +
} // +
}
Здесь мы сначала запоминаем сколько у нас было запаса хода на момент начала движения, а потом просто добавляем к этому значению deltaY и смотрим: можем мы двигаться вверх или нет.
Собственно логика dragUp:
...
dragUp() {
if(this.isBottomOffset()) {
// переместить вверх
return;
}
this.style.transform = 'translateY(0)';
}
...
Пишем метод, который будет перемещать вьюпорт:
class CustomViewport extends HTMLElement {
constructor() {
super();
this.VIEWPORT_HEIGHT = window.innerHeight;
}
connectedCallback() {
this.addEventListener("touchstart", ev => {
this.firstTouch = ev.touches[0];
const rect = this.getBoundingClientRect();
const { height, top } = rect;
this.bottomOffsetBeforeDragging = (height + top) - this.VIEWPORT_HEIGHT;
this.lastPosY = this.bottomOffsetBeforeDragging - this.scrollTop; // +
});
...
}
translateY() { // +
const pixels = this.deltaY + this.lastPosY; // +
this.style.transform = `translateY(${pixels}px)`; // +
this.style.transition = 'none'; // +
} // +
...
}
Разберем поподробнее что такое this.lastPosY и как он высчитывается. Если в css мы писали transform: translateY(calc(100% — 50vh)); где 100% — это высота самого виртуального вьюпорта, а 50vh — это половина высоты реального вьюпорта, и для статического описания положения это подходит хорошо, то для вычисления перемещения в динамике удобнее оперировать абсолютными величинами, именно эти преобразования мы здесь производим.
Итак this.lastPosY — это величина перемещения виртуального вьюпорта в пикселях на момент начала движения, именно к нему мы добавляем this.deltaY и получаем новое положение вьюпорта.
Так как мы определили свойства:
bottom: 0;
transform-origin: 50% 100% 0;
то наша система координат для отсчета передвижения вьюпорта примет вид
Опишем dragDown:
...
dragDown() {
if(this.lastPosY < 0) {
return;
}
this.translateY();
}
...
Собственно событие dragEnd:
class CustomViewport extends HTMLElement {
constructor() {
super();
this.VIEWPORT_HEIGHT = window.innerHeight;
}
connectedCallback() {
this.addEventListener("touchend", ev => { // +
const { mode: currentMode } = this.dataset; // +
this.style = null; // +
if (Math.abs(deltaY) < 10) { // +
this.dataset.mode = currentMode; // +
return; // +
} // +
if (deltaY > 0) { // +
if (currentMode === "inited") { // +
this.dataset.mode = "deleted"; // +
return; // +
} // +
this.dataset.mode = "inited"; // +
return; // +
} // +
this.dataset.mode = "opened"; // +
}); // +
...
В строчке if (Math.abs(deltaY) < 10) мы указываем что если передвинулись меньше чем на 10 пикселей — оставить текущее положение.
В итоге у нас должен получиться компонент наподобие
class CustomViewport extends HTMLElement {
constructor() {
super();
this.VIEWPORT_HEIGHT = window.innerHeight;
}
connectedCallback() {
this.addEventListener("touchstart", ev => {
this.firstTouch = ev.touches[0];
const rect = this.getBoundingClientRect();
const { height, top } = rect;
this.bottomOffsetBeforeDragging = (height + top) - this.VIEWPORT_HEIGHT;
this.lastPosY = this.bottomOffsetBeforeDragging - this.scrollTop;
});
this.addEventListener("touchmove", ev => {
this.deltaY = ev.touches[0].clientY - this.firstTouch.clientY;
return this.deltaY > 0 ? this.dragDown() : this.dragUp();
});
this.addEventListener("touchend", ev => {
const { mode: currentMode } = this.dataset;
this.style = null;
if (Math.abs(this.deltaY) < 10) {
this.dataset.mode = currentMode;
return;
}
if (this.deltaY > 0) {
if (currentMode === "inited") {
this.dataset.mode = "deleted";
return;
}
this.dataset.mode = "inited";
return;
}
this.dataset.mode = "opened";
});
}
dragUp() {
if(this.isBottomOffset()) {
this.translateY();
return;
}
this.style.transform = 'translateY(0)';
}
dragDown() {
if(this.lastPosY < 0) {
return;
}
this.translateY();
}
translateY() {
const pixels = this.deltaY + this.lastPosY;
this.style.transform = `translateY(${pixels}px)`;
this.style.transition = 'none';
}
isBottomOffset() {
return (this.bottomOffsetBeforeDragging + this.deltaY) > 0;
}
}
customElements.define('custom-viewport', CustomViewport);
Данный код не является законченной реализацией, а лишь прототипом. Более детальная проработка скрола, дебоунсы, еще какие-либо оптимизации, touchcancel — оставлены на откуп читателю.