Главная » Хабрахабр » Микроинтеракции в iOS. Лекция Яндекса

Микроинтеракции в iOS. Лекция Яндекса

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Мы знаем, что в UIView все, что касается отображения на экране, это все равно все прокси к CALayer. Что происходит, если у UIView мы обновим backgroundColor. Что же происходит внутри 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, а потом опять вернуться. Значит, нужно обработать и эти события тоже.

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

У нас DevOps. Давайте уволим всех тестировщиков

Можно ли автоматизировать всё, что угодно? Потом всех тестировщиков уволим, конечно. Зачем они теперь нужны, «ручного» тестирования не осталось. Правильно ведь? Здесь будут конкретные цифры и чисто практические выводы, как так получается, что у хороших специалистов всегда есть работа. Это ...

Современный PHP — прекрасен и продуктивен

Почти 8 месяцев тому назад я пересел с проектов python/java на проект на php (мне предложили условия от которых было бы глупо отказываться), и я внезапно не ощутил боли и отчаяния, о которых проповедуют бывшие разработчики на ПХП. И вот ...