Создание элегантной и надежной анимации перетаскивания

Я работаю над библиотекой с открытым исходным кодом, целью которой является обеспечение красивого и доступного перетаскивания для списков в Интернете (react-beautiful-dnd). Одна из целей библиотеки - предоставить возможность перетаскивания мышью, как если бы вы перетаскивали физические объекты. Библиотека старается избегать любых взаимодействий, когда что-то немедленно переключается из одного места в другое. Привязка нарушает визуальный язык движущихся физических объектов, поскольку это не то, как физические объекты ведут себя.

Для большинства взаимодействий в библиотеке отсутствует привязка 🥳. Однако по-прежнему было взаимодействие, содержащее привязку: перемещение между списками.

Терминология

  • 🏠 домашний список: список, в котором началось перетаскивание.
  • ✈️ внешний список: список, в котором не запускался перетаскиваемый элемент.
  • заполнитель: пробел, вставленный в список.

Перемещение между списками

В этом примере заполнитель в 🏠 главном списке сразу же сворачивается после завершения анимации перетаскивания:

В этом блоге я подробно расскажу о своем пути к удалению привязки при перемещении между списками. Как оказалось, для того, чтобы сделать интерфейс элегантным и надежным, нужно было многое сделать.

Моя первая попытка

С помощью Дэниела Керриса (дизайнер Atlassian) я составил стратегию, которая, как я думал, будет именно тем, что мне нужно для удаления привязки при перемещении между списками:

  1. Используйте только один заполнитель в любом списке для перетаскиваемого элемента.
  2. Если над списком нет, показывать заполнитель в 🏠 главном списке.

Вот что нужно делать 🏠 домашнему списку:

  • Мгновенно показать заполнитель, когда началось перетаскивание
  • Анимировать заполнитель закрыт при перетаскивании ✈️ чужого списка
  • Анимируйте заполнитель открыть при возврате к 🏠 главному списку.
  • Мгновенно удалить заполнитель по окончании перетаскивания

Вот как это выглядело:

Больше нечего делать, правда? Если бы я остановился на этом, я бы выпустил супер сломанный опыт. Оказалось, что у этой стратегии есть несколько довольно серьезных недостатков 😭.

Вот что произойдет, если вы сначала переместили элемент вниз, прежде чем перемещать его между списками:

Сложно понять, что происходит, поэтому давайте притормози

Совет: при отладке анимаций замедляйте их 🐢

Что еще хуже, 🏠 домашний список рос повсюду, когда он быстро входил и выходил из ✈️ чужих списков. Это легко заметить при перемещении между ✈️ чужими списками, между которыми есть пробелы.

Итак, оказалось, что мои первоначальные мысли о том, как удалить привязку при перемещении между списками, были довольно плохими. Необходимо было продумать и решить ряд проблем.

Задача 1: перемещение элементов в домашнем списке 🏠

Когда элемент перетаскивается, другие элементы перемещаются в сторону, чтобы освободить место для перетаскиваемого элемента.

Вот как ведут себя элементы в 🏠 главном списке:

Шаг 1: Лифт 🏋️‍♀️

  • Перетаскиваемый элемент (A) удаляется из потока документов. Пространство, занятое A, обычно разрушается из-за этого удаления
  • Заполнитель мгновенно вставляется в место A, чтобы сохранить его место в списке и предотвратить сворачивание списка.
  • B и C не трогаются

Шаг 2: движение вниз ↓

  • Заполнитель остается на исходном месте A
  • B сдвинут вверх на transform, чтобы освободить место для A
  • C не тронут

Шаг 3: Переход к ✈️ иностранному списку →

Здесь дела начинают идти плохо

  • Заполнитель вставляется в ✈️ внешний список, чтобы освободить место для перетаскиваемого элемента A
  • 🔥Заполнитель в 🏠 главном списке анимируется закрытым
  • 🔥 B он transform перевернут вверх, чтобы противодействовать сворачивающемуся заполнителю, чтобы попытаться сохранить B в том же визуальном месте вверху списка
  • C по-прежнему не затронут, но теперь сдвинут вверх за счет закрытия заполнителя в 🏠 главном списке

Моя попытка противодействовать сворачиванию анимации заполнителя привела к B дрожанию. Расширяющийся заполнитель толкает B вниз, а transform пытается подтянуть B вверх. Даже при противоположных анимациях это вызывало некоторую дрожь.

Шаг 4. Вернуться в 🏠 главный список ←

Дела продолжают идти плохо

  • Заполнитель удаляется из ✈️ внешнего списка.
  • 🔥Заполнитель в 🏠 главном списке анимирует открытое в исходном месте перетаскиваемого элемента A
  • 🔥 B применяет transform к сдвигу вверх, чтобы оказаться в верхней части списка.
  • C по-прежнему не затронут, но теперь сдвинут вниз за счет расширения заполнителя

Расширение заполнителя конфликтует со сдвигом вверх на B и вызывает плохую анимацию.

Альтернативный паттерн взаимодействия

В ✈️ иностранных списках все работает немного иначе. Давайте подробнее рассмотрим, как они работают для того же взаимодействия, что и раньше:

Шаг 1: Лифт 🏋️‍♀️

  • Перетаскиваемый элемент (A) удален из потока документов
  • Заполнитель сразу же вставляется в место A, чтобы сохранить место в списке.
  • B и C не трогаются

Шаг 2: Перемещение ✈️ иностранного списка →

  • Заполнитель закрывается в 🏠 главном списке.
  • Заполнитель анимирует открытие в конце ✈️ внешнего списка, чтобы освободить место в списке для перетаскиваемого элемента A
  • B и C сдвигаются вниз с transform

Шаг 3. Возврат к 🏠 главному списку ←

  • Заполнитель анимирует закрытие в ✈️ внешнем списке
  • B и C возвращают свой сдвиг в исходное положение, удаляя их transform
  • Заполнитель анимируется открытием в 🏠 главном списке.

Что можно узнать из шаблона внешнего списка ✈️?

Смещение внешнего списка и шаблон заполнителя не вызывают странной анимации. Это здорово выглядит. Здесь важно отметить, что никакие анимации не борются друг с другом. В 🏠 главном списке были сценарии, в которых анимация развертывания и свертывания заполнителя не работала должным образом при запуске рядом с элементом, пытающимся сместиться вверх или вниз.

Совет: избегайте сценариев, в которых анимации пытаются нейтрализовать друг друга

Новый шаблон главного списка 🏠

Я создал новый шаблон взаимодействия для 🏠 домашнего списка, основанный на том, как работал ✈️ иностранный список.

Шаг 1: Лифт 🏋️‍♀️

  • Перетаскиваемый элемент (A) удален из потока документов
  • Заполнитель вставляется в конец списка. Ранее вместо A был вставлен заполнитель, чтобы список не сворачивался.
  • В результате того, что A был удален из потока документов, это место в списке свернуто, и все, что находится после A , должно быть перемещено вверх
  • В том же обновлении браузера все, что находится после A, мгновенно сдвигается вниз, чтобы предотвратить сворачивание A. Визуально похоже, что вообще ничего не двигалось 👨‍🎨

Шаг 2: движение вниз ↓

  • Удалите начальное неанимированное смещение вниз из B. Удаление этого смещения вниз приведет к перемещению B вверх.
  • Заполнитель остается в конце списка
  • C продолжает перемещаться вперед

Шаг 3: Перемещение ✈️ иностранного списка →

  • Заполнитель закрывается в 🏠 главном списке. При этом никакие элементы в списке не сдвигаются, поскольку они находятся в конце списка.
  • Удалите начальное неанимированное смещение вниз из C, которое заставляет его двигаться вверх. Он перемещается вверх из-за пространства, созданного в списке удалением A при подъеме
  • Заполнитель анимирует открытие в конце ✈️ внешнего списка, чтобы освободить место для перетаскиваемого элемента A

Шаг 4: Вернуться к 🏠 главному списку ←

  • Заполнитель анимирует открытый в конце домашнего списка 🏠. Это не влияет на размещение любого предмета
  • Анимируйте движение C вниз, чтобы освободить место для A
  • B нетронутый

Прекрасный результат 🌹

В этом новом шаблоне устранены проблемы с анимацией, которые были вызваны предыдущим шаблоном домашнего взаимодействия.

Примечание: производительность 🚀

Мгновенное перемещение всего, что находится ниже перетаскиваемого элемента, обычно было бы огромной проблемой с производительностью, так как вы бы изменили множество элементов одновременно. Я ранее создал виртуализированное решение смещения, чтобы преодолеть эту проблему. Основная идея состоит в том, что смещаются только видимые элементы. Я смог использовать эту логику, чтобы убедиться, что перемещение большого количества вещей вперед в 🏠 главном списке во время подъема не является проблемой производительности.

Примечание: жестокий 🥵

Это изменение повлекло за собой обширные модификации всей кодовой базы. В новом шаблоне была представлена ​​идея о том, что некоторые предметы были сдвинуты при подъеме, чтобы оказаться в исходном визуальном положении. Почти каждую функцию нужно было настроить, чтобы приспособиться к этому мысленному (и визуальному) сдвигу.

Задача 2: что делать, когда ничего не закончилось?

По-прежнему оставались некоторые другие проблемы, которые необходимо было решить с моей попыткой избежать привязки при перемещении между списками. Напомню про мусорный пожар 🗑🔥:

Странное поведение в 🏠 домашнем списке моего примера связано с моей попыткой реализовать:

2. При отсутствии списка показывать заполнитель в 🏠 главном списке.

Пробел использовался для обозначения места падения перетаскиваемого элемента. Если пользователь выходит за пределы списка, перетаскиваемый элемент возвращается в исходное положение. Поэтому, когда ничего не осталось, я бы освободил место для перетаскиваемого элемента в 🏠 домашнем списке.

Однако добавление пробела в 🏠 домашнем списке, когда ничего не было, вызывало неприятные ощущения, когда вы быстро переходили и отключались от ✈️ чужих списков. Элементы в 🏠 главном списке начнут освобождать место для перетаскиваемого элемента, чтобы вернуться домой, а затем потребуется быстро удалить это пространство.

Это заставило меня переосмыслить то, как использовалось пространство. Нужно ли нам пространство в главном списке 🏠, когда ничего не осталось? В большом списке, если пользователь прокрутил область просмотра, а исходная позиция больше не видна, то пространство бесполезно для отображения пользователя куда вернется элемент, если его выбросить за пределы списка.

Я решил позволить потребителям библиотеки решать, как они хотели бы сообщить, какой список является 🏠 домашним списком. Я добавил дополнительную информацию в API, чтобы люди знали, какой список является 🏠 домашним списком во время перетаскивания.

В нашем эталонном приложении мы используем цвет для обозначения того, что перетаскивается (розовый). Теперь мы также используем другой цвет при отсутствии списка, чтобы указать, к какому списку элемент вернется, если его уронить (голубой).

Использование цвета для отображения того, в какой список будет помещен список, когда нет ничего лучше, чем использование пробела, поскольку цвет списка может быть виден, даже когда домашнее местоположение больше не отображается.

Больше не освобождает место для перетаскиваемого элемента, когда ничего не происходит, устраняет дрожание в 🏠 главном списке

Испытание 3: прерываемая анимация

Когда пользователи что-то быстро перетаскивают, может потребоваться изменить направление анимации. В нашем новом шаблоне дизайна заполнитель в ✈️ внешнем списке раскрывается при раскрытии и сворачивается при закрытии. Когда пользователь покидает список при расширении заполнителя, мы хотим прервать существующую анимацию расширения и оттуда анимировать сворачивание.

Вот как это выглядит с использованием CSS-анимации:

Оказывается, CSS-анимация не прерывается, тогда как переходы CSS прерываемы

Переходы CSS вносят некоторую сложность, если вы хотите смонтировать расширение элемента. С помощью CSS-анимации все, что вам нужно сделать для анимации монтирования элемента, - это применить animation к элементу, когда он монтируется. Это невозможно сделать за один проход с переходами CSS 😔. Переходы CSS перемещают свойство от одного значения к другому. Значение, с которого начинается свойство CSS, устанавливается как начальное значение и также не передается. Переходы происходят от этого начального значения.

На высоком уровне вот что вам нужно сделать, чтобы выполнить анимированное расширение при монтировании с использованием переходов CSS:

  • Установите элемент без размера (например, height: 0px;). Поместите transition на элемент (например, transition: height 1s ease;)
  • После рендеринга элемента установите желаемый размер (например, height: 100px).

У этого подхода есть некоторые сложности:

  • Чтобы что-то оживить, вам нужно дважды отрендерить элемент. Сначала без размера, а потом с размером
  • Вам нужно управлять тем, что произойдет, если элемент будет демонтирован до начала анимации расширения.

Используя переходы CSS для нашей анимации установки заполнителя, анимация установки может быть прервана.

Задача 4: сворачивание заполнителя 🏠 домашней страницы

Первоначально для этого нового шаблона анимации, как только элемент был перетащен из списка, заполнитель в 🏠 домашнем списке закрывался.

Хотя такое поведение выглядит неплохо, оно имело плохие последствия. Когда начинается перетаскивание, библиотека фиксирует размеры всех соответствующих элементов DOM. С этого момента никаких воспоминаний не происходит.

Если заполнитель удаляется из 🏠 домашнего списка во время перетаскивания, домашний список может сократиться. Сворачивающийся 🏠 домашний список также может привести к перемещению других ✈️ иностранных списков на странице. Эти эффекты не отражаются в размерах, зафиксированных при подъеме.

Очень сложно определить, будет ли свернут 🏠 домашний список при удалении заполнителя и насколько сильно. Есть несколько правил CSS и DOM. Также чрезвычайно сложно определить, повлияет ли сворачивание 🏠 основного списка на размещение ✈️ чужих списков на странице.

Библиотека не может просто исправить сохраненные в ней размеры ожидаемыми изменениями, поскольку изменения неизвестны. Библиотека потенциально может вспомнить размеры элемента DOM, но это очень сложный и медленный путь, который я пробовал раньше.

Вот некоторые проблемы, вызванные сворачиванием 🏠 главного списка:

Проблема: перетаскивание вниз 🏠 главного списка приведет к регистрации как находящегося внутри 🏠 главного списка

Причина: размер главного списка по-прежнему сохраняется как исходный размер, который включает некоторые области ниже видимого дна.

Проблема: перетаскивание ✈️ чужого списка, расположенного под 🏠 начальным списком, приведет к очень странным целям перетаскивания

Причина: домашний список 🏠 сворачивается, а внешний список ✈️ визуально перемещается вверх, но домашний список и внешний список по-прежнему имеют исходные размеры.

Проблема: при перетаскивании в ✈️ внешний список, расположенный под 🏠 исходным списком, будет нерабочая позиция для перетаскивания.

Причина: при расчете места перетаскивания не учитывается сдвиг вверх ✈️ внешнего списка.

Сохранение места в домашнем списке 🏠

Чтобы обойти эти проблемы, я решил оставить заполнитель в 🏠 главном списке для всего перетаскивания, чтобы сохранить исходный размер главного списка 🏠. Затем я бы анимировал заполнитель, закрытый во время падения.

К сожалению, попадание в сложенный иностранный список все еще не работает.

Я анимировал заполнитель в главном списке 🏠, закрытом во время анимации перетаскивания. Это может привести к сворачиванию главного списка 🏠. Любое сворачивание может привести к неверному расчету места перетаскивания, поскольку сворачивание списка не учитывается.

Анимация закрыта после перетаскивания

Чтобы место перетаскивания было правильным, размер заполнителя должен поддерживаться в главном списке 🏠 до завершения анимации перетаскивания. Это позволяет избежать сворачивания любого 🏠 домашнего списка, которое может повлиять на место перетаскивания.

Случайное преимущество: меньше отвлекающих факторов

Изначально мне было немного грустно, что сворачивание заполнителя в 🏠 домашнем списке должно было произойти после анимации перетаскивания. Я показал его Джейку Миллеру (мастер UX в Atlassian), и он предпочел новый подход сворачивания заполнителя в 🏠 домашнем списке после отбрасывания. Он подумал, что отвлекает сворачивание заполнителя в 🏠 главном списке одновременно с анимацией перетаскивания, поскольку слишком много вещей отвлекают внимание пользователя.

«Анимация привлекает внимание пользователя. Если вам нужно анимировать несколько объектов, запускайте их одну за другой, чтобы пользователь мог сосредоточиться на одной части за раз ».
- Джейк Миллер

Анимируя сворачивание заполнителя в главном списке 🏠 после анимации перетаскивания, пользователь не отвлекается от выполняемого им взаимодействия при перетаскивании.

Задача 5. Максимальное взаимодействие с пользователем 🤾‍♀️

До этого момента вся работа была сосредоточена на единственной задаче: избегании привязки. Это может вступить в конфликт с предоставлением пользователю возможности быстро выполнить работу.

В идеале взаимодействие «перетаскивания» должно обеспечивать быстрое перемещение элементов. Я не хочу, чтобы пользователя заставляли ждать завершения анимации, прежде чем он сможет перетаскивать другие объекты.

С новым шаблоном анимации, который позволяет избежать привязки, есть две анимации, которые запускаются после того, как пользователь сигнализирует о том, что он хочет сбросить элемент:

  1. Анимация перетаскивания предмета в новую позицию; затем
  2. Сворачивание заполнителя в 🏠 главном списке

Подъем 🏋️‍♀️

Когда начинается перетаскивание, собираются размеры всех соответствующих элементов DOM. Когда начинается перетаскивание, важно, чтобы все размеры находились в исходном положении и были равны их размерам в состоянии покоя. Захват измерения, когда он находится в процессе свертывания, даст неверную информацию. Итак, чтобы все было правильно, когда пользователь начинает перетаскивать, все движения и анимации должны быть завершены.

Конкурирующие интересы ⚖️

Чтобы собранные размеры были правильными при запуске перетаскивания, необходимо завершить все анимации.

Итак, выбор стоит между:

  1. Заставьте пользователя дождаться завершения всех анимаций перетаскивания, прежде чем он сможет начать новое перетаскивание. Все будет на своих местах. Но это ограничило бы скорость перетаскивания элементов. Или
  2. Сбросьте все анимации бега, чтобы все было на своих местах. Пользователь мог начать новое перетаскивание в любое время. Но это приведет к некоторому щелчку.

Я решил выбрать второй вариант, чтобы пользователи не были ограничены в их взаимодействиях.

Как правило, пользователь вряд ли столкнется с этим сценарием, поскольку анимация перетаскивания выполняется довольно быстро. Однако важно обдумать и продумать все возможные взаимодействия.

Компромиссы

Этот раздел может показаться странным для завершения, учитывая, как я старался избавиться от привязки. Я хотел показать, что стоит гнаться за красивой анимацией, но также важно знать, когда идти на компромисс и какие интересы вы балансируете.

Собираем все вместе

Я считаю, что новый шаблон анимации выглядит потрясающе

Я надеюсь, что это путешествие по созданию элегантных и надежных анимаций перетаскивания оказалось для вас проницательным. Это было намного более долгое и сложное путешествие, чем я ожидал, но я думаю, что результаты того стоят.

Если вы хотите использовать всю тяжелую работу в этом блоге, вы можете использовать react-beautiful-dnd в своих собственных проектах.

Спасибо

Спасибо многим людям, которые помогли мне разобраться с этим проблемным пространством, и другим за их помощь в создании этого блога: Джейку Миллеру, Дэниелу Керрису, Джейсону Шихи, Шону Кертису и Дэниелу Дель Кор.