Как адаптировать вложенные объекты относительно заданного родительского элемента?


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

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

Введение (вода)

Адаптивный дизайн обеспечивает корректное отображение элементов на различных устройствах в зависимости от разрешения (на самом деле не только от разрешения, но в контексте этой статьи пренебрежем другими адаптивными свойствами). Ни для кого не секрет, что адаптивный дизайн является повседневной практикой. Существует большое количество CSS - фреймворков позволяющих создавать адаптивный дизайн быстро и просто, предоставляя инструментарий по работе с сеткой и медиа-запросами. Одним из самых известных CSS - фреймворков является Bootstrap. С помощью Bootstrap можно реализовывать адаптивные и мобильные проекты, а так же использовать и переопределять переменные, миксины и функции sass.

Проблема

На картинке блоки, которые мы адаптируем в зависимости от ширины экрана.

Все тривиально, задача решается в одну строчку. Ширина, которую занимает блок зависит от ширины окна браузера. А что если мы хотим адаптировать блок относительно определённого родительского блока? Например нам необходимо сверстать блок так, чтобы он выглядел как на мобильном устройстве, но при этом ширина окна у нас 1920 px? Вот один из примеров нашего проекта.

Если это встречается всего пару раз в интерфейсе, то можно прописать парочку медиа-запросов и вспомогательные классы для такой адаптации, но если это встречается повсеместно во всем интерфейсе?

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

Решения

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

EQCSS

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

Минусы:

  • Собственный стиль написания CSS
  • Не совместимость или тяжелая интеграция с плагинами и фреймворками
  • Нагрузка на производительность при большом CSS
  • Проблема наследования свойств при вложенности.

CSS-Element-Queries

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

Минусы:

  • Нагрузка на производительность при большом CSS
  • Проблема наследования свойств при вложенности.

Наша Задача

В большинстве своих проектах мы используем Angular и Bootstrap 4. Предыдущие решения в нашей ситуации оказались непригодны по соответствующим минусам в них.

Наша задача заключалась в том, чтобы приспособить сетку Bootstrap и миксины sass к адаптации по элементу. Для этого мы разработали концепцию приемлемую к любому другому JS-фреймворку. Итого наши требования к реализации приняли такой вид:

  • Ограничение вложенности свойств CSS
  • Произвольная установка адаптируемого блока
  • Ограничение адаптации по брейкпоинтам.

Забегу немного вперед, показав, как будет выглядеть html/css в нашем случае:

<app-root>
  <main class="container-fluid" setsize width-breakpoints="xs sm md lg xl">
    <div class="row">
      <div class="col-sm-6" getsize width-breakpoints="xs sm md lg xl">
        Content adaptation by size of a tag main 
      </div>
      <div class="col-sm-6">
        <div setsize width-breakpoints="xs sm md">
          Content adaptation by size of this a column 
        </div>
      </div>
    </div>
  </main>
</app-root>


*[width-breakpoints~=sm] > .col-sm-6 {
  flex: 0 0 50%;
  max-width: 50%;
}

Реализация

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

import { Injectable } from '@angular/core';

@Injectable({
 providedIn: 'root'
})
export class ResponsiveService {
 breakpoints: any = {
  xs: 0,
  sm: 576,
  md: 768,
  lg: 992,
  xl: 1140,
 };

 constructor() { }
}

Следующим шагом была программная реализация логики присвоения ключей breakpoint’ов к элементам html и реагирование на изменение размеров. Благодаря таким понятиям, как директивы в JS-фреймворках, а в нашем случае Angular, довольно просто удается добиться данного результата.

Директива setSize устанавливает родительский элемент - размеры которого будут учтены при адаптации вложенных элементов, реагирование на изменения размеров блоков выполняется на событии «resize», если существуют отдельные события изменения размеров блоков, например открытие/закрытие сайдбар в директиве подписываемся на это события по паттерну eventBus. Вы можете использовать Resize Observable и для поддержки старых браузеров подключать полифилл в «polyfills.ts», который будет следить за изменением размеров элемента без трюков eventBus и событий window resize.

import {
  AfterContentInit, 
  Directive, 
  ElementRef, 
  OnDestroy, 
  OnInit, 
  Renderer2
} from '@angular/core';
import {ResponsiveService} from '../services/responsive.service';

@Directive({
  selector: '[setSize]'
})
export class SetSizeDirective implements OnInit, AfterContentInit, OnDestroy {

  keyAttr = 'width-breakpoints';

  constructor(private elementRef: ElementRef,
              private renderer: Renderer2,
              private responsiveService: ResponsiveService) { }

  ngOnInit() {
    this.resize();
  }

  ngAfterContentInit() {
    this.applySize();
  }

  ngOnDestroy() {
    this.unResize();
  }

  applySize() {
    this.setSize();

    setTimeout(() => {
      this.setSize();
    });
  }

  resize() {
    window.addEventListener('resize', this.setSize.bind(this), false);
  }

  unResize() {
    window.removeEventListener('resize', this.setSize.bind(this), false);
  }

  setSize() {
    const breakpoints = [];

    for (const key of Object.keys(this.responsiveService.breakpoints)) {
      if (this.responsiveService.breakpoints[key] > this.elementRef.nativeElement.offsetWidth) {
        break;
      }

      breakpoints.push(key);
    }


    this.renderer.setAttribute(this.elementRef.nativeElement, this.keyAttr, breakpoints.join(' '));
  }
}

Директива getSize, которая находит родительский элемент с директивой setSize и копирует ее breakpoint’ы, а так же следит за изменениями через Mutation Observer.

import {
  Directive, 
  ElementRef, 
  OnDestroy, 
  OnInit, 
  Renderer2
} from '@angular/core';

@Directive({
  selector: '[getSize]'
})
export class GetSizeDirective implements OnInit, OnDestroy {

  keyAttr = 'width-breakpoints';
  parent: Element;

  // Subscription
  changes$: any;

  constructor(private elementRef: ElementRef,
              private renderer: Renderer2) { }

  ngOnInit() {
    this.findParent();

    this.listenResizeParent();
  }

  ngOnDestroy() {
    this.unListenResizeParent();
  }

  findParent() {
    this.parent = this.elementRef.nativeElement;

    do {
      this.parent = this.parent.parentElement;
    } while (!(this.parent.hasAttribute('getsize') || this.parent.hasAttribute('setsize')));
  }

  listenResizeParent() {
    let attr = this.parent.getAttribute(this.keyAttr);
    this.renderer.setAttribute(this.elementRef.nativeElement, this.keyAttr, attr);

    this.changes$ = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        const newAttr = this.parent.getAttribute(this.keyAttr);
        if (attr !== newAttr) {
          attr = newAttr;
          this.renderer.setAttribute(this.elementRef.nativeElement, this.keyAttr, attr);
        }
      });
    });

    const config = { attributes: true, attributeFilter: [this.keyAttr]};

    this.changes$.observe(this.parent, config);
  }

  unListenResizeParent() {
    if (this.changes$) {
      this.changes$.disconnect();
    }
  }
}



Заключительный шаг приспособить нашу адаптивную систему Bootstrap к адаптации по breakpoint’ам родителя.

 $selector-element-query: ‘width-breakpoints';

@function breakpoint-generate($type, $bp, $breakpoints: $grid-breakpoints) {
  $selector: $selector-element-query;
  $result: '';

  @if ($type == 'up') {
    $result: '[#{$selector}~="#{$bp}"]';
  }

  @if ($type == 'down') {
    $next: breakpoint-next($bp);

    $result: ':not([#{$selector}~="#{$next}"])';
  }

  @if ($type == 'only') {
    $result: '[#{$selector}$="#{$bp}"]';
  }

  @return if($result, $result + ' >', null);
}

@function breakpoint-generate-between($lower, $upper, $breakpoints: $grid-breakpoints) {
  $selector: $selector-element-query;
  $result: '[#{$selector}~="#{$lower}"]';

  $next: breakpoint-next($upper);

  @if $next {
    $result: $result + ':not([#{$selector}~="#{$next}"])';
  }

  @return $result + ' >';
}

@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {
  $n: index($breakpoint-names, $name);
  @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);
}

@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {
  $min: map-get($breakpoints, $name);
  @return if($min != 0, $min, null);
}

@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {
  $next: breakpoint-next($name, $breakpoints);
  @return if($next, breakpoint-min($next, $breakpoints) - .02px, null);
}

@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {
  @return if(breakpoint-min($name, $breakpoints) == null, "", "-#{$name}");
}

@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {
  $selector: breakpoint-generate('up', $name);
  $min: breakpoint-min($name);

  @if $min {
    *#{$selector} {
      @content;
    }
  } @else {
    @content;
  }
}

@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {
  $selector: breakpoint-generate('down', $name);
  $max: breakpoint-max($name, $breakpoints);

  @if $max {
    *#{$selector} {
      @content;
    }
  } @else {
    @content;
  }
}

@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {
  $selector: breakpoint-generate-between($lower, $upper);

  $min: breakpoint-min($lower, $breakpoints);
  $max: breakpoint-max($upper, $breakpoints);

  @if $min != null and $max != null {
    *#{$selector} {
      @content;
    }
  } @else if $max == null {
    @include media-breakpoint-up($lower, $breakpoints) {
      @content;
    }
  } @else if $min == null {
    @include media-breakpoint-down($upper, $breakpoints) {
      @content;
    }
  }
}

@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {
  $selector: breakpoint-generate('only', $name);

  $min: breakpoint-min($name, $breakpoints);
  $max: breakpoint-max($name, $breakpoints);

  @if $min != null and $max != null {
    *#{$selector} {
      @content;
    }
  } @else if $max == null {
    @include media-breakpoint-up($name, $breakpoints) {
      @content;
    }
  } @else if $min == null {
    @include media-breakpoint-down($name, $breakpoints) {
      @content;
    }
  }
}

Вместо переписывания bootstrap, мы переопределяем миксины отвечающие за построение сетки, что позволяет нам использовать bootstrap, как зависимость из node_modules с возможностью обновления. 

@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/images";
@import "~bootstrap/scss/code";

@import "mixins/breakpoints";
@import "~bootstrap/scss/grid";

@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/forms";
@import "~bootstrap/scss/buttons";
@import "~bootstrap/scss/transitions";
@import "~bootstrap/scss/dropdown";
@import "~bootstrap/scss/button-group";
@import "~bootstrap/scss/input-group";
@import "~bootstrap/scss/custom-forms";
@import "~bootstrap/scss/nav";
@import "~bootstrap/scss/navbar";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/breadcrumb";
@import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge";
@import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert";
@import "~bootstrap/scss/progress";
@import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/close";
@import "~bootstrap/scss/toasts";
@import "~bootstrap/scss/modal";
@import "~bootstrap/scss/tooltip";
@import "~bootstrap/scss/popover";
@import "~bootstrap/scss/carousel";
@import "~bootstrap/scss/spinners";
@import "~bootstrap/scss/utilities";
@import "~bootstrap/scss/print";

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