Окончательный эффект выглядит следующим образом:
Нажмите, чтобы просмотретьонлайн демо,а такжеполный код.
CSS transform
Во-первых, вам нужно понять CSS3.transform
,использоватьtransform
Выполните преобразование элемента, что является ключом к реализации.
transform
Наиболее распространенная форма выглядит так:
// 放大 2 倍
transform: scale(2);
// 向左平移 100px
transform: translate(100px);
// rotate,skew,perspective 等其他变换
По сути, приведенное выше написание можно рассматривать как синтаксический сахар, предоставляемый CSS. Студенты, знакомые с компьютерной графикой, могут знать, что фактическая реализация, используемая компьютером для завершения преобразования изображения, представляет собой матрицу.
Если вы используете следующий код JavaScript для изменения и запроса свойства преобразования CSS элемента div:
document.querySelector('div').style.transform = 'scale(1)';
console.log(window.getComputedStyle(document.querySelector('div'), null).getPropertyValue('transform'));
// 输出 "matrix(1, 0, 0, 1, 0, 0)"
Вы можете видеть, что значение преобразования в настоящее время не «масштаб (1)», а матричное представление. 6 параметров в матрице здесь соответствуют 6 значениям, которые работают в матрице аффинного преобразования 2D (полная матрица 3*3, но 3 параметра фиксированы) — но это не то же самое, что реализация из этой статьи. Слишком много дел. Для простоты достаточно знать параметры, используемые в матрице.
Но это точно не совсем правильное использование матрицы
Чтобы узнать больше о матрицах преобразования, выполните поиск по запросу «аффинные преобразования».
На Zhihu есть хороший вводный ответ:Как просто объяснить понятие «аффинное преобразование»? - Ответ от одноклассницы Ма.
Если вы не хотите писать матричную форму, вы также можете записать ее эквивалентно так:
transform: translate(200px, 100px) scale(3);
Уведомление,Порядок записи определяет порядок преобразования, шкала не может быть размещена перед переводом:Is a css transform matrix equivalent to a transform scale, skew, translate.
Сенсорное событие
Перед его реализацией вам нужно немного разобраться в обработке сенсорных событий. смотрите подробностисенсорное событие.
Вот краткое введение в соответствующие события:
touchstart
: начинается событие касания, указывающее, что точка касания начинает касаться. Можно получить, передав объектtouches
, т.е.TouchList
объект, который содержит все текущие точки контакта, а именноtouchобъект. Следующие два события проходят с одними и теми же параметрами.
touchmove
: точка касания перемещается.
touchend
: Событие касания завершается, указывая на то, что точка касания уходит.
TouchList
: объект, похожий на массив, то есть полученный из функции andarguments
Аналогично, не массив, а содержитlength
свойства и0
,1
Такое ключевое значение может быть передано черезArray.prototype.slice
Преобразовать в массив. также можно использоватьtouches['0']
Такой синтаксис берет объект touchpoint непосредственно из касаний.
нужно знать
-
Параметр, передаваемый событию касания, является составным объектом, поэтому, если вы используете фреймворк React, лучше не передавать этот параметр в асинхронные методы, такие как setTimeout, Promise, области async/await. Вы можете использовать переменные, чтобы сначала получить значение, которое вам нужно использовать, а затем передать его. Если он должен быть передан, вы можете использовать
e.persist()
Сохраняйте объект. -
В отличие от событий щелчка, offsetX offsetY нельзя получить напрямую из событий касания. Так что вам нужно рассчитать эти два значения самостоятельно.
Перетащите реализацию
Обычно для поиска и перетаскивания элементов DOM в режиме онлайн используется относительное позиционирование и атрибуты top и left. Но в сочетании с событиями масштабирования в этой статье для достижения цели будет использоваться преобразование.
Но как бы это не было реализовано, идея перемещения элемента одна и та же: сначала вычислить смещение касания в двух событиях перемещения, а затем применить это смещение к цели.
HTML-часть:
<head>
<meta charset="UTF-8">
<!--一些方便实现的声明-->
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<title>Touch</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
// 禁用页面拖动刷新
overscroll-behavior: contain;
}
.board {
width: 100%;
height: 100%;
}
.board img {
width: 260px;
}
</style>
</head>
<body>
<div class="board">
<!--盗了少数派的图-->
<img src="https://cdn.sspai.com/article/86c69914-4545-bc1c-1310-2975d4fe8d6b.jpg?imageMogr2/quality/95/thumbnail/!700x233r/gravity/Center/crop/700x233" alt="">
</div>
</body>
Часть JavaScript:
let img = document.querySelector('img');
// 查询 DOM 对象的 CSS 值
const getStyle = (target, style) => {
let styles = window.getComputedStyle(target, null);
return styles.getPropertyValue(style);
};
// 获取并解析元素当前的位移量
const getTranslate = (target) => {
let matrix = getStyle(target, 'transform');
let nums = matrix.substring(7, matrix.length - 1).split(', ');
let left = parseInt(nums[4]) || 0;
let top = parseInt(nums[5]) || 0;
return { left: left, top: top };
};
// 记录前一次触摸点的位置
let preTouchPosition = {};
const recordPreTouchPosition = (touch) => {
preTouchPosition = {
x: touch.clientX,
y: touch.clientY
};
};
// 应用样式变换
const setStyle = (key, value) => { img.style[key] = value; };
// 添加触摸移动的响应事件
img.addEventListener('touchmove', e => {
let touch = e.touches[0];
let translated = getTranslate(touch.target);
// 移动后的位置 = 当前位置 + (此刻触摸点位置 - 上一次触摸点位置)
let translateX = translated.left + (touch.clientX - preTouchPosition.x);
let translateY = translated.top + (touch.clientY - preTouchPosition.y);
let matrix = `matrix(1, 0, 0, 1, ${translateX}, ${translateY})`;
setStyle('transform', matrix);
// 完成一次移动后,要及时更新前一次触摸点的位置
recordPreTouchPosition(touch);
});
// 开始触摸时记录触摸点的位置
img.addEventListener('touchstart', e => { recordPreTouchPosition(e.touches['0']); });
Реализация масштабирования
Первоначальная реализация
Для масштабирования необходимо знать коэффициент масштабирования. Масштабирование выполняется двумя пальцами, есть две точки касания, а изменение расстояния между точками касания соответствует изменению коэффициента масштабирования, и может быть достигнут эффект масштабирования двумя пальцами. Чтобы узнать изменение масштаба, идея та же, что и в перемещении, и также необходимо записать расстояние до последней точки касания. Затем можно рассчитать текущий коэффициент масштабирования.
let scaleRatio = 1;
// 从变量名就知道它的用途与用法
let preTouchesClientx1y1x2y2 = [];
img.addEventListener('touchmove', e => {
let touches = e.touches;
if (touches.length > 1) {
// 即便同时落下 10 个手指,我们只取前 2 个就好
let one = touches['0'];
let two = touches['1'];
const distance = (x1, y1, x2, y2) => {
let a = x1 - x2;
let b = y1 - y2;
return Math.sqrt(a * a + b * b);
};
// 新的缩放倍率 = (当前指间距离 ÷ 之前指间距离)× 之前缩放倍率
// 没有在 touchstart 中记录最初的双指位置,计算会得到 NaN,对结果直接取 1
scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
setStyle('transform', matrix);
// 及时更新双指位置信息
preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
}
});
img.addEventListener('touchstart', e => {
let touches = e.touches;
// 双指同时落下也是有先后顺序的,当发现多指触摸时进行记录
if (touches.length > 1) {
let one = touches['0'];
let two = touches['1'];
preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
}
recordPreTouchPosition(touches['0']);
});
Базовую функцию масштабирования сейчас реализовали, но, похоже, что-то не так... Почему такое ощущение, что от пальца не передается эффект масштабирования? Кажется, что где бы ни была операция, она начинается с центра картинки.
transform-origin
Краткое введение в свойство CSS:transform-origin
, подробнее см.MDN.
Этот атрибут указывает базовую точку элемента, которая является исходной точкой примененного преобразования.
// 元素基点设置为 (50px, 50px),是元素上的相对坐标
transform-origin: 50px 50px;
Когда графическое преобразование представляет собой только смещение, преобразование-происхождение не имеет никакого эффекта. Но для свойств вращения и масштабирования важным свойством является базовая точка элемента.
И значение по умолчанию для преобразования-источника50% 50%
, который является центром элемента. Вот почему каждый раз, когда вы выполняете операцию масштабирования, кажется, что масштабирование исходит из центра изображения.
Если вы хотите почувствовать эффект зума от пальца, вам нужно установить transform-origin посередине двух пальцев или, посредством вычисления смещения, имитировать смену начала координат. В этой статье используется прежняя, более интуитивная идея.
Получить смещение касания
На самом деле, независимо от того, в какую форму преобразуется элемент, установка начала координат зависит от смещения элемента до преобразования. Как упоминалось ранее, в событии касания нет значения смещения точки касания относительно элемента, поэтому вам нужно вычислить его самостоятельно.
// 计算相对缩放前的偏移量,rect 为当前变换后元素的四周的位置
const relativeCoordinate = (x, y, rect) => {
let cx = (x - rect.left) / scaleRatio;
let cy = (y - rect.top) / scaleRatio;
return {
x: cx,
y: cy
};
};
На самом деле это(所选的屏幕位置 - 元素的屏幕位置) / 缩放比例
, не сложно. rect можно использовать напрямуюgetBoundingClientRect
полученная функция. (ранее неправильно понялgetBoundingClientRect
Полученное местоположение неверно, я реализовал это сам. Идея состоит в том, чтобы использовать родительский элемент позиционирования для накопления смещений, друзья, которые любят вызовы, должны попробовать это сами, там есть сюрпризы)
Что касается «выбранного положения экрана», возьмите положение средней точки двух пальцев. Здесь для расчета выбираются значения clientX и clientY, то есть смещение от браузера.
// 记录变换基点
let scaleOrigin = {};
img.addEventListener('touchmove', e => {
let touches = e.touches;
if (touches.length > 1) {
let one = touches['0'];
let two = touches['1'];
const distance = (x1, y1, x2, y2) => {
let a = x1 - x2;
let b = y1 - y2;
return Math.sqrt(a * a + b * b);
};
scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
// 移动基点
let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2, img.getBoundingClientRect());
scaleOrigin = origin;
setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
setStyle('transform', matrix);
preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
}
});
Вроде сделано? Попробуйте. эммм... вы можете обнаружить, что после еще нескольких операций, каждый раз, когда вы отдаляете масштаб от руки и снова приближаете, целевой объект совершенно не контролируется и даже телепортируется.
Изменить проблемы, вызванные преобразованием-происхождением
Немного подумав, мы можем найти проблему (ее не было, я долго отлаживал): для элементов, которые уже применяли масштабирование (или поворот), при изменении исходной позиции произойдет резкое изменение на позиции.
Что именно произошло? На самом деле, это проблема, которую можно объяснить с помощью школьной математики.
Немного школьной математики
Это случай, когда базовая точка элемента находится в начале координат. В это время коэффициент масштабирования равен 2, координаты точки A до масштабирования — (3, 2), а после преобразования — (6, 4).
Так что, если происхождение не в начале? При перемещении источника в (1, 1) ситуация следующая:
Как видите, координаты точки A' стали (5, 3). На самом деле мы уже можем увидеть небольшую подсказку по числовому значению, но давайте проведем небольшую абстрактную индукцию.
Сначала установите базовую точку O как. В этот момент, если координаты точки А равны
, коэффициент масштабированияs. Использование вектора для представления расстояния от точки A до базовой точки:
Тогда расстояние от точки А' до базовой точки равноизsраз:
Координаты точки А' равныЗначение плюс координаты точки O:
Если мы переместим базовую точку O, теперь координаты точки O станут:. Мы не изменили точку отсчета системы координат, координаты точки А по-прежнему
, расстояние от точки А до базовой точки О равно:
А расстояние от новой точки А'', преобразованное из точки А в базовую точку О (изsраз) становится:
Координаты точки А'' в это время равныДобавьте координаты точки O:
То есть, изменив базовую точку O сперейти к
, запишите приращение как
, что приводит к преобразованию точки A из
,стал
. Вычислить значение масштабированного изображения точки А, изменяющееся за счет перемещения базовой точки элемента, то есть расстояние от точки А'' до точки А':
Вы можете ввести реальные значения координат на картинке выше для проверки, и результат будет таким, как ожидалось.
Устранение последствий изменения исходного местоположения
Это имеет смысл, вВ тот момент, когда два пальца опускаются, исходные координаты меняются, и возьмем любую точку X на результате преобразования, изменение
. Было замечено, что это значение не имеет ничего общего с координатами самой точки X и является «фиксированным значением», определяемым расстоянием перемещения начала координат; то есть все точки на элементе одновременно производят эффект преобразования это конечное смещение. Отраженный на интерфейсе, момент, когда два пальца касаются элемента, элемент немедленно «телепортируется». Поскольку палец продолжает менять положение, исходная точка постоянно сбрасывается, что приводит к ситуации, когда увеличенный элемент полностью выходит из-под контроля.
Чтобы устранить негативные последствия модификации происхождения, необходимо сделать 2 вещи:
- Измените смещение при изменении источника, чтобы эффект смещения целевой точки был смещен.
- Уменьшение количества модификаций источника может сократить ненужные вычисления.
вносить исправления
В предыдущем расчете мы получили, что элемент произошелПеревод, поэтому вам нужно только заранее вычесть это значение из величины смещения при изменении исходной позиции. Кроме того, мы уменьшили частоту модификации источника с одного раза на событие touchmove до одного раза для «полного масштабирования».
// 增加 originHaveSet 全局变量,每次设置 origin 位置后设为 true
img.addEventListener('touchmove', e => {
// ...
if (!originHaveSet) {
originHaveSet = true;
// 移动视线中心
let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2,
img.getBoundingClientRect());
// 修正视野变化带来的平移量,别忘了加上之前已有的位移值啊!
translateX = (scaleRatio - 1) * (origin.x - scaleOrigin.x) + translateX;
translateY = (scaleRatio - 1) * (origin.y - scaleOrigin.y) + translateY;
setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
scaleOrigin = origin;
}
// ...
});
img.addEventListener('touchstart', e => {
let touches = e.touches;
if (touches.length > 1) {
// ... 开始缩放事件时,将标志置为 false
originHaveSet = false;
} //...
});
Когда я снова посмотрел на эффект, я не мог не расплакаться от умиления. Наконец-то я могу нормально увеличивать масштаб, этот идеальный эффект продолжения, этот плавный процесс масштабирования...
Подожди, убери палец после масштабирования, почему картинка все равно иногда прыгает?
маленький хвост
Тщательная проверка кода показала, что позиционирование связано с проблемой перемещения касанием одной рукой: две руки масштабируются и удаляются, иногда вызывая логику перемещения касания одной рукой; а положение последней точки касания, используемой в реализации перетаскивания, было не обновляется вовремя, в результате чего расчетное расстояние перемещения изображения не соответствует фактическому.
Затем добавьте логику обновления для touchend и touchcancel:
img.addEventListener('touchend', e => {
let touches = e.touches;
if (touches.length === 1) {
recordPreTouchPosition(touches['0']);
}
});
// touchcancel 同样
Полный код доступен на моем github:html-drag-scale-demo.
Еще слишком молод — дополнительная обработка браузерного перетаскивания
После того, как друзья напомнили мне в области комментариев, я обнаружил, что использовалoverscroll-behavior: contain;
Атрибуты не влияют на браузеры с китайскими характеристиками, такие как WeChat X5 (например, браузер Quark, ничего личного), и такие проблемы, как обновление по запросу, все равно будут возникать.
Если вам нужно только дополнить фиксацию браузера при перетаскивании изображений, достаточно добавить строчку кода:
img.addEventListener('touchmove', e => {
// ...
e.preventDefault();
};
Но если вы также хотите заблокировать настраиваемое событие раскрывающегося списка браузера, это потребует больших усилий. Следующий код реализует блокировку всех событий touchmove на странице, включая события прокрутки для длинных страниц.
// 检查是否支持 options 写法
let passiveSupport = false;
try {
let option = Object.defineProperty({}, 'passive', {
get: () => {
passiveSupport = true;
}
});
window.addEventListener('passivetest', null, option);
} catch (err) {}
document.body.addEventListener('touchmove', (e) => {
e.preventDefault();
}, passiveSupport ? { passive: false } : false);
Если вам интересно, как пишется пассив, перейдите к этой статье:Шаг по пассивному прослушивателю событий; и официальная документация для addEventListener :EventTarget.addEventListener(), и объяснение Google рациональности пассива:Improving Scroll Performance with Passive Event Listeners.
Пробовал звонить в тот момент, когда тело находится вверху страницы и продолжает опускатьсяe.preventDefault()
, который работает при прокрутке страницы вниз. Но у браузера есть правило: нельзя прерывать непрерывную прокрутку. То есть, если страница сначала вытягивается, а затем вытягивается вниз, у страницы все еще есть возможность выполнить поведение вытягивания по умолчанию.
Короче говоря, если вы хотите одновременно отключить событие раскрывающегося списка и быть полностью совместимым с действием прокрутки, на данном этапе это может оказаться невозможным. (Если есть мастера, которые сделали это, пожалуйста, не стесняйтесь, дайте мне знать)
Кроме того, приведенный выше код можно заменить кодом CSS, если вам все равно.Safari не совместим со всеми сериямиесли:
html { touch-action: none; }