Несколько недель назад в офисе Яндекса прошло специальное мероприятие сообщества CocoaHeads — более масштабное, чем традиционные митапы. Разработчик Антон Сергеев выступил на этой встрече и рассказал о модели микроинтеракций, которой обычно пользуются UX-дизайнеры, а также о том, как применить заложенные в ней идеи на практике. Больше всего внимания Антон уделил анимации.


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

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

Но сначала немного отвлечемся. Задумайтесь, помните ли вы, когда решили стать разработчиком? Я это четко помню. Все началось с таблицы. Однажды я решил выучить ObjC. Модный язык, весело, просто так, без далеко идущих планов. Я нашел книжку, кажется, Big Nerd Ranch, и начал читать главу за главой, выполнять каждое упражнение, проверял, читал, пока не дошел до таблицы. Тогда я впервые познакомился с паттерном delegate, точнее с его подвидом «Источник данных», data source. Эта парадигма кажется сейчас мне очень простой: есть data source, delegate, все просто. Но тогда мне это взорвало мозг: как можно отделить таблицу от совершенно разных данных? Вы когда-то видели таблицу на листе бумаги, в которую можно поместить бесконечное количество строк, совершенно абстрактные данные. На меня это очень сильно повлияло. Я понял, что программирование, разработка имеет колоссальные возможности, и применять их будет очень интересно. С тех пор я решил стать разработчиком.

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

Сегодня я поговорю про еще один подход, который перенимает еще одну гуманитарную вещь. В частности — про микроинтеракции.

Все началось с лоадера. На прошлой работе, до Яндекса, у меня была задача повторить гугловый material design loader. Там их два, один неопределенный, другой определенный. У меня была задача совместить их в один, он должен был уметь и определенные, и неопределенные, но имелись жесткие требования — чтобы было крайне плавно. В любой момент мы можем перейти из одного состояния в другое, и все должно плавно и аккуратно анимироваться.

Я умный разработчик, я все сделал. У меня получилось больше 1000 строк кода непонятной лапши. Оно работало, но на код-ревью я получил классное замечание: «Я очень надеюсь, что этот код никто никогда не будет править». И для меня это практически профнепригодность. Я написал ужасный код. Он работал круто, это была одна из лучших моих анимаций, но код был ужасный.

Сегодня я постараюсь описать подход, который я обнаружил после того, как ушел с той работы.



Начнем с самой гуманитарной темы — моделей микроинтеракций. Каким образом они встроены и вообще где они спрятаны в наших приложениях? Продолжим с применением этой модели в нашем техническом мире. Рассмотрим, как UIView, который занимается отображением и анимацией, как работает это. В частности, много поговорим про механизм CAAction, который тесно встроен в UIView, CALayer и с ним работает. И затем рассмотрим небольшие примеры.

Сначала определение. Видимо, автору очень понравилась приставка «микро», но никаких макро- или наноинтеракций нет, размер не имеет значения. Для простоты будем называть их просто интеракциями. Это такая удобная модель, которая позволяет описать любое взаимодействие с приложением, от начала до конца. Состоит из четырех пунктов: триггер, бизнес-логика, которую необходимо реализовать в этой интеракции, обратная связь, чтобы донести что-то пользователю, и изменение состояния приложения.

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

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

Прежде, чем это приложение появилось, и я смог в нем что-то поискать, его сначала разрабатывали. Что же думал UX-дизайнер, когда придумывал этот процесс? Все началось с того, что необходимо было выйти из немой сцены, когда пользователь и приложение смотрят друг на друга, и ничего не происходит. Для этого был нужен какой-то триггер. У всего есть начало, и здесь тоже необходимо было с чего-то начинать.

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

Казалось бы, можно было на этом закончить. Ведь мы решили задачу, нашли все аптеки. Есть только одна проблема: пользователь ничего не знает до сих пор про эти аптеки. Ему нужно это донести.

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

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

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

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

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

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

При нажатии кнопка меняет свое состояние, она становится нажатой. Когда пользователь отпускает — перестает быть нажатой. То есть она дает обратную связь пользователю, чтобы тот понимал, что от этой кнопки ожидать. И кнопка может находиться в нажатом, ненажатом, быть активной или неактивной, в разных состояниях, и переходить по различной логике из одного состояния в другое.

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

И это очень удобная модель, которая позволяет упрощать взаимодействие внутри команды и описывать программно, разделять четыре сущности: триггер, бизнес-логика, обратная связь и изменение состояния. Посмотрим, что предоставляет нам UIKit, чтобы это использовать. И не просто предоставляет, а именно использует. При реализации различных анимаций, маленький компонентов подклассов UIView, она только этот механизм и использует, и по другому пути не идет.

Начнем с UIView, каким образом оно ложится в эту модель. Затем рассмотрим CALayer, что он нам предоставляет, чтобы поддерживать эти состояния, и рассмотрим механизм действий, самый интересный момент.

Начнем с UIView. Мы его используем, чтобы отображать какие-то прямоугольники на экране. Но на самом деле UIView себя рисовать не умеет, она использует для этого другой объект CALayer. На самом деле UIView занимается тем, что получает сообщения о касании системы, а также о других вызовах, о том API, который мы определили в наших подклассах UIView. Таким образом сам UIView реализует логику триггера, то есть запуск каких-то процессов, получая эти сообщения от системы.

Еще UIView может уведомлять свои делегаты о случившихся событиях, а также рассылать сообщения подписчикам, как, например, делают подклассы UIControl различными событиями. Таким образом реализуется бизнес-логика этого UIView. Не все они обладают бизнес-логикой, многие из них являются только элементами отображения и не имеют обратной связи в смысле бизнес-логики.



Мы рассмотрели два пункта, триггер и бизнес-логику. А где же в UIView спрятана обратная связь и изменение состояний? Чтобы это понять, надо вспомнить, что UIView не существует само по себе. Оно при создании создает себе backlayer, подкласс CALayer.



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

Как отличить одно состояние от другого? Они различаются набором данных, которые нужно где-то хранить. Мы рассмотрим, какие нам возможности предоставляет CALayer для UIView, чтобы он хранил состояние.



У нас немного расширяется интерфейс, взаимодействие между UIView и CALayer, у UIView появляется дополнительное задание — обновлять хранилище внутри CALayer.

Малоизвестный факт, которым мало кто пользуется: CALayer может вести себя как ассоциативный массив, который означает, что мы можем записать в него произвольные данные по любому ключу следующим образом: setValue(_:forKey:).



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

Реализуется это свойством Style, который есть у любого CALayer. По дефолту оно nil, но мы его можем переопределить и использовать.



Вообще, это обычный словарь и ничего больше, но у него есть особенность о том, как работает с ним CALayer, если мы запросим value forKey, еще один метод, который есть у NSObject. Он действует очень интересно, он ищет нужные значения в словаре style рекурсивно. Если мы запакуем один стайл существующий в новый стайл с ключом style и туда пропишем какие-то ключи, но он будет следующим образом искать.



Сначала посмотрит в корень, затем вглубь и так далее, до тех пор пока это будет иметь смысл. Когда style станет nil, то дальше искать смысла нет.

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

Закончили с хранилищем, начинаем с CAAction. Про него расскажу подробнее.



Возникает новая задача у UIView — запрашивать у CALayer экшены. Что такое экшены?



CAAction — это всего-навсего протокол, у которого всего один метод — run. Apple вообще любит киношную тематику, action здесь как «камера, мотор!». Вот «мотор» — это как раз action, и не просто так это название было использовано. Метод run означает запустить действие, которое может запуститься, выполниться и закончиться, что самое важное. Этот метод очень generic, у него только строка event, а все остальное может быть любого типа. В ObjC это все id и обычный NSDictionary.



Внутри UIKit есть классы, которые удовлетворяют протоколу CAAction. Во-первых, это animation. Во-первых, мы знаем, что animation можно добавлять на слой, но это очень низкоуровневая штука. Высокоуровневая абстракция над ним — запустить action с нужными параметрами со слоем.

Второе важное исключение — NSNull. Мы знаем, что ему нельзя никакие методы вызывать, но он удовлетворяет протоколу CAAction, и это сделано для того, чтобы удобно искать CAAction у слоев.



Как мы раньше говорили, UIView является делегатом у CALayer, и один из методов делегата — action(for:forKey:). У слоя же есть метод, action forKey.



Мы можем в любой момент вызвать его у слоя, и он в любой момент отдаст правильный action или nil, так как он тоже может отдавать. Алгоритм очень необычный поиска. Здесь псевдокод написан, давайте рассмотри по строчкам. При получении такого сообщения он сначала консультируется у делегата. Делегат может либо вернуть nil, что будет означать, что следует продолжить поиск в другом месте, либо может вернуть корректный экшен, корректный объект, который который удовлетворяет протоколу CAAction. Но есть логичное правило: если он вернет NSNull, который удовлетворяет этому протоколу, то впоследствии он будет преобразован в nil. То есть если мы вернем Null, фактически это будет означать «прекратить поиск». Экшена нет и не надо.

Но при этом есть следующий. После того, как он с делегатом проконсультировался, и делегат вернул nil, он продолжает искать. Сначала в словаре Actions, который есть у слоя, а затем будет рекурсивно искать в словаре style, где также может быть словарь с ключом actions, в который можно записывать многие экшен, и он также рекурсивно будет уметь их искать. Если уж и там не получилось, то он запросит у классового метода default action forKey, который определен у CALayer и до недавних пор что-то возвращал, но в последнее время возвращает всегда nil в последних версиях iOS.

Разобрались с теорией. Давайте посмотрим, как все на практике применяется.

Есть события, у них есть ключи, по этим событиям происходят какие-то действия. Принципиально можно выделить два разных типа событий. Первое — анимация хранимых свойств. Допустим, когда мы вызываем у View backgroundcolor = red, то это теоретически возможно заанимировать.



Какой же доклад про паттерны без схемы? Я нарисовал парочку. У UIView есть какой-то интерфейс, который мы определили у подклассов или тот, который получают от системы с событиями. Задача UIView — запросить нужный экшен, обновить внутренний store и запустить тот экшен, который произошел. Порядок очень важен по поводу запроса: действие, только потом обновление экшена, и только потом обновления store и экшена.



Что происходит, если у UIView мы обновим backgroundColor. Мы знаем, что в UIView все, что касается отображения на экране, это все равно все прокси к CALayer. Он все, что получает, на всякий случай кеширует, но при этом тут же все транслирует CALayer, и всей логикой занимается дальше CALayer. Что же происходит внутри CALayer, когда он получает задание изменить бэкграунд? Здесь все немного сложнее.



Для начала он спросит экшен. И важно понимать, что сначала будет запрошен экшен. Это позволит в момент создания экшена спросить у CALayer его текущие значения, в том числе backgroundColor, только потом будет обновлен store, и когда полученный экшен получит команду run, он сможет проконсультироваться у CALayer и получить новые значения. Таким образом он будет обладать как старыми, так и новыми, и это позволит ему создать анимацию, если в этом есть необходимость.

Но есть одна особенность в UIView, если мы изменяем backgroundColor в UIView, если мы это делаем в блоке анимаций, то он анимируется, а если вне блока анимаций, то не анимируется.



Все очень просто, никакой магии нет. Но достаточно помнить, что UIView является делегатом у CALayer, у него есть такой метод. Все очень просто.

Если этот метод был запущен в блоке анимаций, то он вернет какой-то экшен. Если вне блока анимаций, то этот метод вернет NSNull, что означает, что анимировать ничего не нужно. Таким образом прервет естественный поток действий, когда CALayer нормальным образом должен был быть санимирован.

Но что если мы хотим сами добавить, у UIView есть набор свойств, которые анимированные. А что если мы хотим свое такое сделать свойство. Неужели это приватное и никак не оформить?



На самом деле нет, все очень просто. У UIView есть классовая переменная, которая read only, с которой можно проконсультироваться о текущем, наследуемом inheritedAnimationDuration. Это свойство очень простое. В случае если находится внутри анимации, потенциально оно может быть больше нуля. Во всех остальных случаях оно ноль.

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



Что если мы хотим создать свое свойство, а не CAAction, не backgroundcolor или opacity, которые есть уже в UIView анимированные. Казалось бы, нам необходимо заново реализовывать эту логику, запрос действия, обновление стора, запуск действия. Но на самом деле за нас это все уже сделано. И в методе setValue forKey все это уже делается, достаточно просто передать нужное значение по нужному ключу, он сам запросит нужный экшен, сам обновит стор, и сам его запустит с нужным ключом, потом эту анимацию можно будет вычислить, получив их условия.

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

Второй тип событий — это когда мы анимируем нехранимые свойства. Например, можем подать команду «активируйся» или «деактивируйся» в смысле лоадера. Начать крутиться или перестать.

Здесь еще одна схема.



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





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

Все началось с лоадера. Выглядит это как-то так.

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



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



Я начал развивать эту схему дальше. И получилось что-то такое.



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

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



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

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

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

Как я подошел? Я распутал эту схему. Я понял, что у нас есть шесть состояний и всего пять событий. Лоадер может быть вне экрана — это одно состояние. Рассмотрим это на примере состояния activating, когда мы переходим из состояния inactive в active. В данный момент он находится во временном состоянии, оно есть, и в этот момент могут приходить разные сообщения.



Мы ограничиваем набор сообщений. Их всего пять, системные onOrderIn и onOrderOut. Это сообщения, которые посылает система и сам UIKit, когда он появляется на экране и когда пропадает.

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



Выглядело это примерно так. Я смог сделать интерфейс подкласса UIView очень тонким, в нем содержалось всего два свойства: isActive и progress. Но эти два свойства преобразовывались в пять событий. Мне лишь оставалось для каждого состояния написать CAAction, который может обработать каждое событие.

Берем декартово произведение, события и состояния. Получаем пять событий, шесть состояний, 30 CAACtion, которые мне понадобилось написать. Но это был не один большой метод в тысячи строк, это были 30 классов, и подавляющее большинство из них были просто NSNull. На самом деле у 15 классов длина была меньше 15 строк. Это очень простой класс. А вообще в программировании простота — высшая ценность. Сложный код плохой, а простой — он и есть простой и хороший.

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

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

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

Комментарии (1)


  1. DnV
    12.11.2018 18:04

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