Pull to refresh

Как сделать пошаговый гайд вашего приложения (если ваш проект на Angular)

Reading time5 min
Views4.8K
Всем привет.

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

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

Архитектура


Архитектура данного компонента довольно проста.

У нас есть важный элемент в DOM-дереве, о котором мы хотим что-то рассказать пользователю, например, кнопка.

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

image

Для решения этой задачи нам поможет @angular/cdk. В своей прошлой статье я уже хвалил @angular/material которая зависит от CDK, для меня она по-прежнему остается примером качественно исполненных компонентов, созданных с использованием всех возможностей фреймворка.

Такие компоненты как меню, диалоговые окна, тултипы из библиотеки @angular/material сделаны с помощью компонента из CDK — Overlay.

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

Для начала установим библиотеки

npm i @angular/cdk @angular/material -S

После установки не забудьте добавить стили в style.css

@import '~@angular/cdk/overlay-prebuilt.css';
@import '~@angular/material/prebuilt-themes/deeppurple-amber.css';

Теперь создадим новый модуль в нашем проекте:

ng generate library intro-lib

И сразу же cгенерируем шаблон для директивы:

ng generate directive intro-trigger

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

@Directive({
  selector: '[libIntroTrigger]'
})
export class IntroTriggerDirective {
  @Input() libIntroTrigger: string;

  constructor(private introLibService: IntroLibService, private elementRef: ElementRef) {}

  @HostListener('click') showGuideMessage(): void {
    this.introLibService.show$.emit([this.libIntroTrigger, this.elementRef]);
  }
}

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

Для начала объявим в сервисе новое свойство

show$ = new EventEmitter<[string, ElementRef]>();

Как мы уже видели эти значения нам будет присылать директива, в массиве первый элемент это описание, а второй — DOM-элемент который мы описываем (такое решение я выбрал для простоты примера).

@Injectable({
  providedIn: 'root'
})
export class IntroLibService {
  private overlayRef: OverlayRef;
  show$ = new EventEmitter<[string, ElementRef]>();

  constructor(private readonly overlay: Overlay, private readonly ngZone: NgZone, private readonly injector: Injector) {
    this.show$.subscribe(([description, elementRef]: [string, ElementRef]) => {
      this.attach(elementRef, description);
    });
  }
}

Обновим конструктор сервиса, добавив подписку на обновления от EventEmitter, функция attach будет получать обновления и создавать слои.

Для создания слоя нам понадобиться Overlay, Injector и NgZone.

Следующие действия можно разделить на несколько этапов:

  • Закрыть текущий оверлей (если он есть)
  • Создать PositionStrategy
  • Создать OverlayRef
  • Создать PortalInjector
  • Прикрепить компонент к слою

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

Существует несколько готовых стратегий:

  1. FlexibleConnectedPositionStrategy
  2. GlobalPositionStrategy

Если простыми словами, то

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

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

Добавим метод создания стратегии плавающего окна вокруг исследуемого элемента

{
 ...
private getPositionStrategy(elementRef: ElementRef): PositionStrategy {
    return this.overlay
      .position()
      .flexibleConnectedTo(elementRef)
      .withViewportMargin(8) // добавляем отступы от краев экрана
      .withGrowAfterOpen(true) // этот флаг помогает в случае если элемент может изменять размер (например, exspansion panel при открытии)
      .withPositions([ // задаем позиции расположения, при обновлениях позиции эта стратегия будет выбирать лучший вариант из перечисленного 
        {
          originX: 'start',
          originY: 'bottom',
          overlayX: 'start',
          overlayY: 'top'
        },
        {
          originX: 'start',
          originY: 'top',
          overlayX: 'start',
          overlayY: 'bottom'
        },
        {
          originX: 'end',
          originY: 'bottom',
          overlayX: 'end',
          overlayY: 'top'
        },
        {
          originX: 'end',
          originY: 'top',
          overlayX: 'end',
          overlayY: 'bottom'
        }
      ]);
  }
...
}

Добавим метод создания OverlayRef

createOverlay(elementRef: ElementRef): OverlayRef {
    const config = new OverlayConfig({
      positionStrategy: this.getPositionStrategy(elementRef),
      scrollStrategy: this.overlay.scrollStrategies.reposition()
    });

    return this.overlay.create(config);
  }

И добавим метод привязки нашего компонента к слою:

  attach(elementRef: ElementRef, description: string): void {
    if (this.overlayRef && this.overlayRef.hasAttached()) {
      this.overlayRef.dispose();
    }

    this.overlayRef = this.createOverlay(elementRef);

    const dataRef = this.ngZone.run(
      () => new DataRef(this.overlay, this.injector, this.overlayRef, elementRef, description)
    ); // тут следует отметить, что процедуры которые вызываются внутри конструктора, нужно совершить внутри зоны, иначе CD не узнает что что-то нужно перерисовать

    const injector = new PortalInjector(this.injector, new WeakMap([[DATA_TOKEN, dataRef]]));

    dataRef.overlayRef.attach(new ComponentPortal(IntroLibComponent, null, injector));
  }

Вот так у нас выглядит компонент показывающий сообщение

@Component({
  selector: 'lib-intro-lib',
  template: `
    <mat-card>
      <mat-card-content> {{ data.description }}</mat-card-content>
    </mat-card>
  `,
  styles: ['mat-card {width: 300px; margin: 32px;}']
})
export class IntroLibComponent {
  constructor(@Inject(DATA_TOKEN) public data: DataRef) {}
}

Про все что приведено в листингах уже рассказал, кроме DataRef.

DataRef — это простой класс который мы добавляем в инжектор для компонентов, фактически для передачи данных для отрисовки — например описание.

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

export class DataRef {
  shadowOverlayRef: OverlayRef;

  constructor(
    private overlay: Overlay,
    private injector: Injector,
    public overlayRef: OverlayRef,
    public elementRef: ElementRef,
    public description: string
  ) {
    const config = new OverlayConfig({
      positionStrategy: this.overlay.position().global(),
      scrollStrategy: this.overlay.scrollStrategies.block()
    });

    this.shadowOverlayRef = this.overlay.create(config);

    this.shadowOverlayRef.attach(
      new ComponentPortal(
        ShadowOverlayComponent,
        null,
        new PortalInjector(this.injector, new WeakMap([[DATA_TOKEN, this.elementRef]]))
      )
    );
  }
}


ShadowOverlayComponent — рисует компонент, и в инжекторе получает тот же токен, только с элементом, вокруг которого нужно сделать акцент.

Как я это реализовал, можно посмотреть в исходниках на github, отдельно на этом внимание акцентировать не хочется.

Только скажу, что там я рисую canvas на весь экран, рисую фигуру вокруг элемента, и заливаю контекст методом fill('evenodd');

Итого


Самое крутое, это то что @angular/cdk/overlay позволяет нам рисовать сколько угодно слоев. Они будут адаптивными и гибкими. Нам не нужно заботиться о том что изменится размер экрана, или элементы сместятся по каким то естественным причинам, overlay — подумает об этом за нас.

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

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

Спасибо за внимание.
Tags:
Hubs:
+4
Comments0

Articles