Построение жидкостных интерфейсов

Как создавать естественные жесты и анимацию на iOS

На WWDC 2018 дизайнеры Apple выступили с докладом под названием «Проектирование жидких интерфейсов», в котором объясняются конструктивные соображения, стоящие за жестовым интерфейсом iPhone X.

Презентация Apple WWDC18 «Проектирование жидкостных интерфейсов»

Это мой любимый доклад на WWDC - я очень рекомендую его.

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

Немного Swift-подобного кода из презентации.

Если вы попытаетесь реализовать эти идеи, вы можете заметить разрыв между вдохновением и реализацией.

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

Восемь (8) интерфейсов мы создадим. Кнопки, пружины, пользовательские взаимодействия и многое другое!

Вот схема того, что мы рассмотрим:

  1. Краткое изложение выступления «Проектирование интерфейсов жидкостей».
  2. Восемь плавных интерфейсов, теория проектирования и код для их создания.
  3. Приложения для дизайнеров и разработчиков.

Что такое флюидные интерфейсы?

Жидкий интерфейс также можно назвать «быстрым», «гладким», «естественным» или «волшебным». Это опыт без трения, который просто кажется «правильным».

Презентация WWDC рассказывает о флюидных интерфейсах как о «расширении вашего разума» и «расширении мира природы». Интерфейс плавный, когда он ведет себя так, как думают люди, а не так, как думают машины.

Что делает их текучими?

Жидкие интерфейсы являются отзывчивыми, прерываемыми и перенаправляемыми. Вот пример жеста «проведи домой» на iPhone X:

Приложения могут быть закрыты во время анимации запуска.

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

Почему мы заботимся о плавных интерфейсах?

  1. Жидкие интерфейсы улучшают взаимодействие с пользователем, делая каждое взаимодействие быстрым, легким и значимым.
  2. Они дают пользователю ощущение контроля, которое создает доверие к вашему приложению и вашему бренду.
  3. Их сложно построить. Текучий интерфейс трудно скопировать и может быть конкурентным преимуществом.

Интерфейсы

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

Иконки, представляющие восемь (8) интерфейсов, которые мы будем строить.

Интерфейс № 1: кнопка калькулятора

Это кнопка, которая имитирует поведение кнопок в приложении калькулятора iOS.

Ключевая особенность

  1. Основные моментально на ощупь.
  2. Быстро постукивать, даже когда в середине анимации.
  3. Пользователь может нажать и перетащить за пределы кнопки, чтобы отменить нажатие.
  4. Пользователь может касаться, перетаскивать наружу, перетаскивать обратно и подтверждать касание.

Теория дизайна

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

Слайды из презентации WWDC, показывающие, как жесты параллельно с мыслью ускоряют действия.

Критический Кодекс

Первым шагом для создания этой кнопки является использование подкласса UIControl, а не подкласса UIButton. UIButton будет работать нормально, но, поскольку мы настраиваем взаимодействие, нам не понадобятся какие-либо его функции.

CalculatorButton: UIControl {
    public var value: Int = 0 {
        didSet {label.text = «\ (value)»}
    }
    личная ленивая переменная метка: UILabel = {...} ()
}

Далее мы будем использовать UIControlEvents для назначения функций различным сенсорным взаимодействиям.

addTarget (self, action: #selector (touchDown), для: [.touchDown, .touchDragEnter])
addTarget (self, action: #selector (touchUp), для: [.touchUpInside, .touchDragExit, .touchCancel])

Мы группируем события touchDown и touchDragEnter в одно «событие», называемое touchDown, и мы можем сгруппировать события touchUpInside, touchDragExit и touchCancel в одно событие, называемое touchUp.

(Для описания всех доступных UIControlEvents, проверьте документацию.)

Это дает нам две функции для обработки анимации.

приватный var animator = UIViewPropertyAnimator ()
@objc private func touchDown () {
    animator.stopAnimation (правда)
    backgroundColor = selectedColor
}
@objc private func touchUp () {
    animator = UIViewPropertyAnimator (продолжительность: 0,5, кривая: .easeOut, анимация: {
        self.backgroundColor = self.normalColor
    })
    animator.startAnimation ()
}

На TouchDown мы отменяем существующую анимацию, если необходимо, и мгновенно устанавливаем цвет на выделенный цвет (в данном случае светло-серый).

На TouchUp, мы создаем новый аниматор и запускаем анимацию. Использование UIViewPropertyAnimator позволяет легко отменить анимацию выделения.

(Примечание: это не точное поведение кнопок в приложении калькулятора iOS, которое позволяет касанию, начатому в другой кнопке, активировать его, если касание было перетащено внутри кнопки. В большинстве случаев кнопка, подобная той, Я создал здесь предназначенное поведение для кнопок iOS.)

Интерфейс № 2: Spring Animations

Этот интерфейс показывает, как можно создать весеннюю анимацию, указав «демпфирование» (оживление) и «отклик» (скорость).

Ключевая особенность

  1. Использует «дружественные дизайну» параметры.
  2. Нет понятия длительности анимации.
  3. Легко прерываемый.

Теория дизайна

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

Несколько дополнительных напоминаний при разработке весенней анимации:

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

Критический Кодекс

В UIKit мы можем создать анимацию весны с помощью UIViewPropertyAnimator и объекта UISpringTimingParameters. К сожалению, нет инициализатора, который просто принимает демпфирование и отклик. Самый близкий мы можем получить инициализатор UISpringTimingParameters, который принимает массу, жесткость, демпфирование и начальную скорость.

UISpringTimingParameters (масса: CGFloat, жесткость: CGFloat, демпфирование: CGFloat, начальная скорость: CGVector)

Мы хотели бы создать удобный инициализатор, который принимает демпфирование и отклик и отображает его на требуемую массу, жесткость и демпфирование.

Немного физики, мы можем получить уравнения, которые нам нужны:

Решение для постоянной пружины и коэффициента демпфирования.

С этим результатом мы можем создать наши собственные UISpringTimingParameters с точными параметрами, которые мы желаем.

extension UISpringTimingParameters {
    удобство инициализации (демпфирование: CGFloat, ответ: CGFloat, initialVelocity: CGVector = .zero) {
        пусть жесткость = Pow (2 * .pi / ответ, 2)
        пусть влажный = 4 * .pi * демпфирование / ответ
        self.init (масса: 1, жесткость: жесткость, демпфирование: влажность, начальная скорость: начальная скорость)
    }
}

Так мы будем указывать весеннюю анимацию для всех остальных интерфейсов.

Физика позади весенних анимаций

Хотите углубиться в весеннюю анимацию? Проверьте этот невероятный пост Кристианом Шнорром: Демистификация UIKit Spring Animations.

Прочитав его пост, весенние анимации наконец-то меня зацепили. Огромный крик Кристиану за то, что он помог мне понять математику, стоящую за этими анимациями, и за то, что научил меня решать дифференциальные уравнения второго порядка.

Интерфейс № 3: Кнопка фонарика

Еще одна кнопка, но с совершенно другим поведением. Это имитирует поведение кнопки фонарика на экране блокировки iPhone X.

Ключевая особенность

  1. Требуется преднамеренный жест с 3D прикосновением.
  2. Бодрость намекает на нужный жест.
  3. Тактильная обратная связь подтверждает активацию.

Теория дизайна

Apple хотела создать кнопку, которая была бы легко и быстро доступна, но не могла быть нажата случайно. Требование силы давления, чтобы активировать фонарик - отличный выбор, но ему не хватает денег и обратной связи.

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

Критический Кодекс

Чтобы измерить величину силы, прилагаемой к кнопке, мы можем использовать объект UITouch, предоставленный в событиях касания.

переопределить func touchesMoved (_ touch: установить , с событием: UIEvent?) {
    super.touchesMoved (касается, с событием)
    сторож пусть коснуться = дотронется до первого {возвращение}
    let force = touch.force / touch.maximumPossibleForce
    let scale = 1 + (maxWidth / minWidth - 1) * сила
    transform = CGAffineTransform (scaleX: scale, y: scale)
}

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

Поскольку кнопка может быть нажата, но еще не активирована, нам нужно отслеживать ее текущее состояние.

enum ForceState {
    сброс регистра, активирован, подтвержден
}
приватный let resetForce: CGFloat = 0.4
приватная активация letForce: CGFloat = 0,5
приватное подтверждение подтвержденияForce: CGFloat = 0,49

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

Для тактильной обратной связи мы можем использовать генераторы обратной связи UIKit.

приватный let activFeedbackGenerator = UIImpactFeedbackGenerator (style: .light)
приватное подтверждение letFeedbackGenerator = UIImpactFeedbackGenerator (style: .medium)

Наконец, для оживленной анимации мы можем использовать UIViewPropertyAnimator с пользовательскими инициализаторами UISpringTimingParameters, которые мы создали ранее.

let params = UISpringTimingParameters (демпфирование: 0,4, отклик: 0,2)
let animator = UIViewPropertyAnimator (продолжительность: 0, параметры синхронизации: параметры)
animator.addAnimations {
    self.transform = CGAffineTransform (scaleX: 1, y: 1)
    self.backgroundColor = self.isOn? self.onColor: self.offColor
}
animator.startAnimation ()

Интерфейс № 4: резиновая лента

Резиновая лента происходит, когда вид сопротивляется движению. Например, когда прокручиваемое представление достигает конца своего содержимого.

Ключевая особенность

  1. Интерфейс всегда отзывчив, даже если действие недопустимо.
  2. Несинхронизированное отслеживание касания указывает границу.
  3. Количество движения уменьшается дальше от границы.

Теория дизайна

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

Критический Кодекс

К счастью, резиновая лента проста в реализации.

смещение = Pow (смещение, 0,7)

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

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

var offset = touchPoint.y - originalTouchPoint.y
смещение = смещение> 0? pow (смещение, 0,7): -pow (-offset, 0,7)
view.transform = CGAffineTransform (translationX: 0, y: offset)

Примечание. Это не то, как Apple выполняет резиновую связку с такими элементами, как прокрутка. Мне нравится этот метод из-за его простоты, но есть более сложные функции для разных типов поведения.

Интерфейс № 5: Приостановка ускорения

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

Ключевая особенность

  1. Пауза рассчитывается на основе ускорения жеста.
  2. Более быстрая остановка приводит к более быстрой реакции.
  3. Нет таймеров.

Теория дизайна

Жидкие интерфейсы должны быть быстрыми. Задержка от таймера, даже если она короткая, может привести к замедлению работы интерфейса.

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

Критический Кодекс

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

частные переменные скорости = [CGFloat] ()
частный трек (скорость: CGFloat) {
    if velocities.count 

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

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

если абс (скорость)> 100 || abs (смещение) <50 {return}
let ratio = abs (firstRecordedVelocity - скорость) / abs (firstRecordedVelocity)
если соотношение> 0,9 {
    pauseLabel.alpha = 1
    feedbackGenerator.impactOccurred ()
    hasPaused = true
}

Мы также проверяем, чтобы движение имело минимальное смещение и скорость. Если жест потерял более 90% своей скорости, мы считаем его приостановленным.

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

Интерфейс № 6: Вознаграждение Импульса

Ящик с открытыми и закрытыми состояниями, который имеет бодрость, основанную на скорости жеста.

Ключевая особенность

  1. Нажатие на ящик открывает его без упругости.
  2. Щелчок ящика открывает его с бодростью.
  3. Интерактивный, прерываемый и обратимый.

Теория дизайна

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

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

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

Критический Кодекс

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

Класс InstantPanGestureRecognizer: UIPanGestureRecognizer {
    переопределить func touchesBegan (_ touch: установить , с событием: UIEvent) {
        super.touchesBegan (касается, с: событие)
        self.state = .began
    }
}

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

if yVelocity == 0 {
    animator.continueAnimation (withTimingParameters: nil, durationFactor: 0)
}

Для обработки жеста со скоростью нам сначала нужно вычислить его скорость относительно общего оставшегося смещения.

let FrareRemaining = 1 - animator.fractionComplete
let distanceRemaining = фракцияRemaining * closedTransform.ty
if distanceRemaining == 0 {
    animator.continueAnimation (withTimingParameters: nil, durationFactor: 0)
    сломать
}
letlativeVelocity = abs (yVelocity) / distanceRemaining

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

let timerParameters = UISpringTimingParameters (демпфирование: 0,8, отклик: 0,3, initialVelocity: CGVector (dx :lativeVelocity, dy :lativeVelocity))
let newDuration = UIViewPropertyAnimator (длительность: 0, хронометража: хронометража).
let durationFactor = CGFloat (newDuration / animator.duration)
animator.continueAnimation (withTimingParameters: timerParameters, durationFactor: durationFactor)

Здесь мы создаем новый UIViewPropertyAnimator, чтобы вычислить время, которое должна занять анимация, чтобы мы могли предоставить правильный durationFactor при продолжении анимации.

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

Интерфейс № 7: FaceTime PiP

Воссоздание интерфейса «картинка в картинке» приложения iOS FaceTime.

Ключевая особенность

  1. Легкое, воздушное взаимодействие.
  2. Прогнозируемая позиция основана на скорости замедления UIScrollView.
  3. Непрерывная анимация, которая учитывает начальную скорость жеста.

Критический Кодекс

Наша конечная цель - написать что-то вроде этого.

let params = UISpringTimingParameters (демпфирование: 1, отклик: 0.4, initialVelocity :lativeInitialVelocity)
let animator = UIViewPropertyAnimator (продолжительность: 0, параметры синхронизации: параметры)
animator.addAnimations {
    self.pipView.center = ближайшиеCornerPosition
}
animator.startAnimation ()

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

Во-первых, давайте посчитаем начальную скорость.

Для этого нам нужно рассчитать относительную скорость на основе текущей скорости, текущей позиции и целевой позиции.

letlativeInitialVelocity = CGVector (
    dx :lativeVelocity (forVelocity: speed.x, от: pipView.center.x, до: nearCornerPosition.x),
    dy :lativeVelocity (forVelocity: speed.y, от: pipView.center.y, до: nearCornerPosition.y)
)
funclativeVelocity (для скорости скорость: CGFloat, от currentValue: CGFloat, до targetValue: CGFloat) -> CGFloat {
    guard currentValue - targetValue! = 0 else {return 0}
    скорость возврата / (targetValue - currentValue)
}

Мы можем разделить скорость на составляющие x и y и определить относительную скорость для каждого из них.

Далее, давайте посчитаем угол для анимации PiP.

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

let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let скорость = распознаватель. скорость (в: вид)
let projectedPosition = CGPoint (
    x: pipView.center.x + project (initialVelocity: speed.x, decelerationRate: decelerationRate),
    y: pipView.center.y + project (initialVelocity: speed.y, decelerationRate: decelerationRate)
)
let nearCornerPosition = nearCorner (to: projectedPosition)

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

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

Мы можем использовать функцию проецирования, представленную в разделе «Проектирование жидкостных интерфейсов», чтобы рассчитать окончательное проектируемое положение.

/// Пройденное расстояние после замедления до нулевой скорости с постоянной скоростью.
проект func (initialVelocity: CGFloat, скорость замедления: CGFloat) -> CGFloat {
    return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}

Последний отсутствующий фрагмент - это логика поиска ближайшего угла на основе прогнозируемой позиции. Для этого мы можем перебрать все угловые позиции и найти ту, которая находится наименьшим расстоянием до проектируемой позиции посадки.

func nearCorner (указать: CGPoint) -> CGPoint {
    var minDistance = CGFloat.greatestFiniteMagnitude
    var closestPosition = CGPoint.zero
    для позиции в pipPositions {
        let distance = point.distance (to: position)
        если расстояние 

Подводя итоги окончательной реализации: Мы используем скорость замедления UIScrollView, чтобы проецировать движение пипса в его конечное положение покоя, и вычисляем относительную скорость, чтобы передать все это в UISpringTimingParameters.

Интерфейс № 8: Вращение

Применение концепций из интерфейса PiP к анимации вращения.

Ключевая особенность

  1. Использует проекцию, чтобы уважать скорость жеста.
  2. Всегда заканчивается в правильной ориентации.

Критический Кодекс

Код здесь очень похож на предыдущий интерфейс PiP. Мы будем использовать те же строительные блоки, за исключением замены функции nearCorner для функции closestAngle.

проект func (...) {...}
funclativeVelocity (...) {...}
func closestAngle (...) {...}

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

let timesParameters = UISpringTimingParameters (
    демпфирование: 0,8,
    ответ: 0,4,
    initialVelocity: CGVector (dx :lativeInitialVelocity, dy: 0)
)

Внутри аниматор игнорирует значение dy и использует значение dx для создания временной кривой.

Попробуй сам!

Эти интерфейсы гораздо веселее на реальном устройстве. Чтобы поиграть с этими интерфейсами самостоятельно, демо-приложение доступно на GitHub.

Демонстрационное приложение Fluid Interfaces, доступное на GitHub!

Практическое применение

Для дизайнеров

  1. Думайте об интерфейсах как о текучих средах выражения, а не о коллекциях статических элементов.
  2. Рассмотрим анимацию и жесты на ранней стадии процесса проектирования. Такие инструменты компоновки, как Sketch, являются фантастическими, но не обеспечивают полной выразительности устройства.
  3. Прототип с разработчиками. Пригласите дизайнерских разработчиков, которые помогут вам создавать прототипы анимации, жестов и тактильных ощущений.

Для разработчиков

  1. Примените советы из этих интерфейсов к своим собственным компонентам. Подумайте, как они могут сочетаться новыми и интересными способами.
  2. Просвещайте своих дизайнеров о новых возможностях. Многие не знают о полной силе 3D-прикосновений, тактильных ощущений, жестов и весенней анимации.
  3. Прототип с дизайнерами. Помогите им увидеть свои проекты на реальном устройстве и создайте инструменты, которые помогут им более эффективно проектировать.

Если вам понравился этот пост, пожалуйста, оставьте несколько хлопков.

Вы можете хлопать до 50 раз, так что нажимайте / постукивайте!

Пожалуйста, поделитесь этой публикацией со своими друзьями-дизайнерами iOS и друзьями-разработчиками iOS в своем любимом социальном медиа.

Если вам нравятся такие вещи, вы должны подписаться на меня в Twitter. Я публикую только качественные твиты. twitter.com/nathangitter

Спасибо Дэвиду Окуну за пересмотр проектов этого поста.