(с) Freepik Storyset
(с) Freepik Storyset

Доброго времени суток, уважаемые читатели!

Меня зовут Евгений Когтев, я ведущий разработчик в команде Web Core в ДомКлик. Мы уже рассказывали подробнее о команде и стоящих перед ней задачах, если не читали, то рекомендую. А сегодня я расскажу, зачем нужен онбординг и как его реализовать в UI-kit.

Задача

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

Требования

  1. Каждый шаг может иметь вид подсказки (hint) или всплывающего окна (modal).

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

  3. Возможность анимированного перехода между элементами (плавная прокрутка страницы).

  4. Полупрозрачный тёмный overlay.

  5. Подсвечиваемая область должна быть с белой обводкой шириной 8 пикселей и радиусом закруглений в 5 пикселей.

Готовые инструменты

Основные инструменты, которые удовлетворяют минимальным требованиям:

  • Intro.js

  • React-joyride

  • Driver.js

  • Shepherd

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

  1. box-shadow: rgba(0, 0, 0, 0.5) 0 0 0 5000px; — огромная тень вокруг элемента.

  2. outline: 5000px solid rgba(0, 0, 0, .5); — огромная рамка, не влияющая на размеры самого блока

  3. mix-blend-mode: hard-light; — интересный и современный способ смешивания фоновых цветов overlay и элемента.

  4. Накладываем на элемент overlay, внутри которого белая подложка. Затем циклично проходим скриптом по всем родителям элемента и убираем им z-index, тем самым поднимая подсвечиваемый контент выше всего остального.

  5. Генерируем прозрачную SVG-картинкe с вырезом под выделяемый элемент.

Чтобы максимально уменьшить размер компонентов, решил писать свою реализацию. Вдохновлённый разнообразием интересных вариантов, захотел сделать нечто вроде SVG, только с использованием Canvas.

Реализация по пунктам

1) С hint и modal проблем не возникло, так как они уже были в нашем UI kit'е со схожим API.

2) Здесь тоже не возникло проблем благодаря простому компоненту из kit'а — popper, в котором после вычисления расположения вызывается callback и передаётся параметром нужный флаг (удаётся ли расположить подсказку в видимой области), благодаря которому можем показать модальное окно.

3) Определяем по расположению компонента, надо ли до него пролистывать, и вызываем метод из внутренней библиотеки utils — функцию (она пролистывает вверх или вниз до ближайшей точки, в которой полностью виден hint + offset) с указанием цели, времени на анимацию и отступ от края экрана:

const { top, bottom } = nextElement.getBoundingClientRect();

if (!(top >= offset && bottom + offset <= document.documentElement.clientHeight)) {
  animatedScrollTo({
    element: nextElement,
    duration,
    offset,
  });
}

4) Начнем с создания компонента:

import React, { useState, useEffect, useRef } from 'react';
import styles from './Onboarding.scss';

const Overlay = ({ targetStyles }) => {
  const [scrollY, setScrollY] = useState(0);
  const canvasRef = useRef();

  const handleRepaint = () => {
    const canvas = canvasRef.current;

    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;

    canvas.width = windowWidth;
    canvas.height = windowHeight;
  };

  useEffect(handleRepaint, [scrollY, targetStyles]);

  useEffect(() => {
    const handleScroll = () => setScrollY(window.scrollY);

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return (
    <div className={styles.overlay}>
      <canvas ref={canvasRef} />
    </div>
  );
};

export { Overlay };

Компонент рисуем через createPortal, растягиваем на весь экран обёртку, а также сам canvas. Подписываемся на изменения расположения или размеров цели и прокрутки. Получаем контекст для отрисовки в canvas и рисуем само затемнение в виде прямоугольника fillRect с заливкой fillStyle и прозрачностью globalAlpha. В конце сбрасываем цвет заливки на белый и убираем прозрачность:

const handleRepaint = () => {
  ...
  const context = canvas.getContext('2d');

  context.fillStyle = overlayColor;
  context.globalAlpha = overlayOpacity;

  context.fillRect(0, 0, windowWidth, windowHeight);

  context.fillStyle = '#ffffff';
  context.globalAlpha = 1;
};

Вызываем нашу функцию для отрисовки прозрачного скруглённого прямоугольника с белой скруглённой обводкой:

// в targetStyles все свойства с учетом белой обводки
// для корректного расположения hint'а без сдвигов
const { width, height, top, left } = targetStyles;
const x = left - window.scrollX;
const y = top - window.scrollY;

roundClearRect({ ctx: context, x, y, width, height, radius });

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

// функция для рисования прямоугольника со скруглениями,
// путём отрисовки каждого угла по отдельности
// с помощью кривой линии методом quadraticCurveTo
const roundRect = ({ ctx, x, y, width, height, radius = 5, fill, stroke }) => {
  const bottom = y + height;
  const right = x + width;

  ctx.beginPath();
  ctx.moveTo(x + radius, y);
  ctx.lineTo(right - radius, y);
  ctx.quadraticCurveTo(right, y, right, y + radius);
  ctx.lineTo(right, bottom - radius);
  ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
  ctx.lineTo(x + radius, bottom);
  ctx.quadraticCurveTo(x, bottom, x, bottom - radius);
  ctx.lineTo(x, y + radius);
  ctx.quadraticCurveTo(x, y, x + radius, y);
  ctx.closePath();
  if (fill) {
    ctx.fill();
  }
  if (stroke) {
    ctx.stroke();
  }
};

const roundClearRect = ({ ctx, x, y, width, height, radius }) => {
  roundRect({
    ctx,
    x,
    y,
    width,
    height,
    radius: 5,
    fill: true,
  });
  ...
};

Для прозрачной области применим композицию globalCompositeOperation = 'destination-out'. Остается нарисовать скруглённый прямоугольник, который будет отражать прозрачную область на текущем Canvas-слое. Для этого используем нашу функцию. Она корректно рисует скругления вплоть до радиуса в 25 пикселей. Поскольку кому-то может понадобиться выделить, например, круглую иконку, то будем считать, что всё более 25 пикселей — это круг. Реализуем его с помощью метода arc, который принимает первыми двумя параметрами x и y — центр элемента, — затем радиус, а также угол начала и конца окружности:

const roundClearRect = ({ ctx, x, y, width, height, radius }) => {
  ...
  ctx.globalCompositeOperation = 'destination-out';

  if (radius >= 25) {
    ctx.beginPath();
    ctx.arc(x + width / 2, y + height / 2, width / 2 - BORDER_WIDTH, 0, 2 * Math.PI);
    ctx.closePath();
    ctx.fill();
  } else {
    roundRect({
      ctx,
      x: x + BORDER_WIDTH,
      y: y + BORDER_WIDTH,
      width: width - BORDER_WIDTH * 2,
      height: height - BORDER_WIDTH * 2,
      radius,
      fill: true,
    });
  }
};

На этом работа с canvas окончена, давайте посмотрим, что получилось:

Сразу бросаются в глаза две проблемы: размытость и артефакты.

  1. Размытость на дисплеях с разрешением Retina.

  2. Артефакты фоновой подложки.

Проблему размытости решаем с помощью функции, которая масштабирует слой canvas на соотношение физического пикселя к логическому:

const PIXEL_RATIO = (function () {
  const ctx = document.createElement('canvas').getContext('2d');
  const pixelRatio = window.devicePixelRatio || 1;
  const backingStorePixelRatio = ctx.webkitBackingStorePixelRatio
    || ctx.mozBackingStorePixelRatio
    || ctx.msBackingStorePixelRatio
    || ctx.oBackingStorePixelRatio
    || ctx.backingStorePixelRatio || 1;

  return pixelRatio / backingStorePixelRatio;
}());

function makeCanvasHiPPI(canvas) {
  canvas.style.width = `${canvas.width}px`;
  canvas.style.height = `${canvas.height}px`;

  canvas.width *= PIXEL_RATIO;
  canvas.height *= PIXEL_RATIO;

  const context = canvas.getContext('2d');
  context.scale(PIXEL_RATIO, PIXEL_RATIO);

  return context;
}

Артефакты убираем с помощью добавления полупикселя к x и y.

Конфигурация

Так как мы стараемся упростить коллегам из других команд жизнь, предоставляем им свойства для конфигурации визуальной части каждого шага (title, subtitle, header, buttons и т.п.), чтобы не тратили время на вёрстку:

const steps = [
  target: document.getElementById('first-el'),
	size: "large",
	placement: "top",
	align: "center",
	title: <div className="title">Первый этап</div>,
	subtitle: <div className="subtitle">Подзаголовок</div>,
	header: <Image />
];

<Onboarding steps={steps} showCounter />}

Результат

desktop
desktop
mobile
mobile

Заключение

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