Задача. Описать изменение значения CSS-свойства как функцию от ширины вьюпорта без использования медиа-запросов. Результатом работы миксина должна быть единственная строка вида <свойство>: <функция от ширины вьюпорта >. В качестве входных данных имеются заданные (табулированные) точки (ширина вьюпорта, значение свойства). Поведение CSS-свойства от точки к точке аппроксимируется прямой линией.

В сети достаточно много разных способов решений для частных случаев (см., например, https://habr.com/ru/post/501392/). Здесь же предлагается общее решение задачи.

Например, нужно описать такое «мифическое» поведение свойства margin-left, как приведено на рисунке:

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

@include adaptiveValueModified("margin-left", 320, 5, 575, 10, 576, -10, 767, -10, 768, 20, 992, 40, 
  1199, 30, 1200, 50, 1920, 60);

а в файле стилей «на выходе» будет всего одна (хоть и длинная) строка:

margin-left: calc(min(0.625rem, -0.07966rem + 1.96078vw) 
+ clamp(-0.625rem, 719.375rem + -2000vw, 0.625rem) 
+ clamp(-0.625rem, -1438.75rem + 3000vw, 1.25rem) 
+ clamp(1.25rem, -3.03571rem + 8.92857vw, 2.5rem) 
+ clamp(1.875rem, 5.49517rem + -4.83092vw, 2.5rem) 
+ clamp(1.875rem, -1496.875rem + 2000vw, 3.125rem) 
+ max(3.125rem, 2.08333rem + 1.38889vw) - 8.75rem);

Очевидно, что чем сложнее (больше диапазонов) поведение свойства, тем длиннее будет и функция-результат, и наоборот.

Ограничения. Таким образом (на данный момент) можно адаптировать только те свойства, значения которых задаются в единицах длины. Приведенная реализация миксина «принимает» на входе значения, заданные в пикселях (без указания “px”), а результат выводит в относительных единицах rem.

Ниже приведен код миксина и вспомогательных функций.

//  Вспомогательная функция для миксина adaptiveValueModified
@function defineLineParameters($breakPoints, $i) {
   // Определяет параметры линии, заданной парой точек {x1, y1} и {x2, y2}
   // Точки берутся из списка $breakPoints, который имеет вид (x1, y1, x2, y2, ..., xN, yN)
   // Индекс $i задает выбор начальной точки  - $x1
   // Результат - список параметров (наклон, точка пересечения оси Y, формула прямой)

   $x1: nth($breakPoints, 2 * $i - 1);
   $x2: nth($breakPoints, 2 * $i + 1);
   $y1: nth($breakPoints, 2 * $i);
   $y2: nth($breakPoints, 2 * ($i + 1));

   $slope: ($y2 - $y1) / ($x2 - $x1);
   $yIntersection: -($x1 * $slope) + $y1;

   $flyValue: 0; // Оптимизация $flyValue - избавление от члена вида 0 * vw
   @if ($slope == 0) {
      $flyValue: #{rem($yIntersection)};
   } @else {
      @if ($yIntersection != 0) {
         $flyValue: #{rem($yIntersection)} + #{$slope * 100}vw;
      } @else {
         $flyValue: #{$slope * 100}vw;
      }
   }
   $lineParameters: ();
   $lineParameters: append($lineParameters, $slope);
   $lineParameters: append($lineParameters, $yIntersection);
   $lineParameters: append($lineParameters, $flyValue);
   @return $lineParameters;
}

@function rem($px) {
   $result: $px / 16 + rem;
   @return $result;
}

   // Миксин, основной блок
@mixin adaptiveValueModified($property, $breakPoints...) {
   // Миксин, который воспроизводит произвольное заданное пользователем поведение (изменение) значения свойства.
   //
   // Поведение задается (табулируется) контрольными точками ($breakpoints).
   // Вывод в CSS -- в относительных единицах rem.
   //
   // Параметры:
   // $property - свойство, которое нужно адаптировать ("font-size", "margin-top", "padding", ...)
   //
   // $breakPoints -- список точек определяющих поведение свойства $property в формате
   //    $значениеШириныВьюпорта1, $значениеСвойства1, ..., $значениеШириныВьюпортаN, $значениеСвойстваN.
   //    Величины задаются в пикселях без указания единицы измерения.
   //    $значениеШириныВьюпорта задаются строго в возрастающем порядке (положительные числа),
   //    равных значений быть не должно:
   //    $значениеШириныВьюпорта1 < $значениеШириныВьюпорта2 < ... < $значениеШириныВьюпортаN
   //    Ограничений на $значениеСвойства нет -- любое положительное или отрицательное число, и ноль.
   //
   // Примеры:
   //    adaptiveValueModified("font-size", 320, 20, 900, 30, 1600, 40) --
   //    на 320px значение "font-size" равно 20px, на 900px - увеличивается до 30px, на 1600px - до 40px,
   //    больше 1600px - продолжает расти (как и на участке от 900 до 1600px), меньше 320px -- продолжает падать
   //    (как и на участке от 900 до 320px).
   //
   //    adaptiveValueModified("margin-top", 320, 5, 991, 5, 992, 20, 1600, 40, 1700, 40) --
   //    на 320px значение "margin-top" равно 5px, до 991px - остается равным 5px, при 992px - скачкообразно возрастает
   //    до 20px, на 1600px - увеличивается до 40px, больше 1600px - остается постоянным и равно 40px.
   //
   //    Если ширина вьюпорта меньше, чем $значениеШириныВьюпорта1, то поведение свойства аппроксимируется
   //    линией с отрезка [$значениеШириныВьюпорта1, $значениеШириныВьюпорта2]; если больше, чем $значениеШириныВьюпортаN,
   //    то линией с отрезка [$значениеШириныВьюпорта(N-1), $значениеШириныВьюпортаN].

   $breakPointsLength: length($breakPoints); // Длина аргумента $breakPoints (должна быть парным числом)
   $breakPointsPair: $breakPointsLength / 2; // Количество точек, задающих поведение свойства $property
   $breakPointsEven: $breakPointsPair - round($breakPointsPair);
   @if ($breakPointsEven != 0) {
      // Проверка на парность длины аргумента $breakPoints
      @error "Список параметров слишком краток -- возможно задано непарное количество точек.";
   }
   @if ($breakPointsLength < 4) {
      // Проверяем, чтоб имелось минимальное количество аргументов при вызове миксина. Если нет --
      // выводим ошибку
      @error "Список параметров слишком краток, не хватает точек для определения поведения свойства!";
   } @else {
      @if ($breakPointsPair == 2) {
         // Если заданы только две пары точек - моментальный вывод в CSS
         $slope: (nth($breakPoints, 4) - nth($breakPoints, 2)) / (nth($breakPoints, 3) - nth($breakPoints, 1));
         $yIntersection: -(nth($breakPoints, 1) * $slope) + nth($breakPoints, 2);
         $flyValue: #{rem($yIntersection)} + #{$slope * 100}vw;

         $propertyValue: "";
         @if ($slope == 0) {
            $propertyValue: rem($yIntersection);
         } @else {
            @if ($yIntersection == 0) {
               $propertyValue: #{$slope * 100}vw;
            } @else {
               $propertyValue: #{"calc(" + $flyValue + ")"};
            }
         }
         #{$property}: $propertyValue;
      } @else if ($breakPointsPair == 3) {
         // Если заданы только три пары точек - оптимизируем результат, вывод в CSS
         $slopeList: ();
         $flyValueList: ();

         @for $i from 1 through 2 {
            $lineParameters: defineLineParameters($breakPoints, $i);
            $slope: nth($lineParameters, 1);
            $yIntersectionList: nth($lineParameters, 2);
            $flyValue: nth($lineParameters, 3);
            $slopeList: append($slopeList, $slope);
            $flyValueList: append($flyValueList, $flyValue);
         }

         $propertyValue: #{nth($flyValueList, 1)};
         @if (nth($slopeList, 2) > nth($slopeList, 1)) {
            $propertyValue: #{"max(" + $propertyValue + ", " + nth($flyValueList, 2) + ")"};
         } @else {
            $propertyValue: #{"min(" + $propertyValue + ", " + nth($flyValueList, 2) + ")"};
         }
         #{$property}: $propertyValue;
      } @else {
         // Количество пар данных больше трех

         // Вычисление константы
         $cumul: 0;
         @for $i from 2 through ($breakPointsPair - 1) {
            $cumul: $cumul + nth($breakPoints, 2 * $i);
         }

         // Основной блок
         $propertyValue: "";
         $propertyValueList: ();
         @for $i from 1 through ($breakPointsPair - 1) {
            $lineParameters: defineLineParameters($breakPoints, $i);
            $y1: nth($breakPoints, 2 * $i);
            $y2: nth($breakPoints, 2 * ($i + 1));
            $slope: nth($lineParameters, 1);
            $yIntersection: nth($lineParameters, 2);
            $flyValue: nth($lineParameters, 3);

            @if ($slope == 0) {
               // Значение свойства НЕ изменяется на участке $x1, $x2
               $cumul: $cumul - $yIntersection;
            } @else {
               // Значение свойства изменяется на участке $x1, $x2
               @if ($i == 1) {
                  // Обработка первой записи в результат
                  @if ($slope > 0) {
                     $propertyValue: #{"min(" + rem($y2) + ", " + $flyValue + ")"};
                  } @else {
                     $propertyValue: #{"max(" + rem($y2) + ", " + $flyValue + ")"};
                  }
                  $propertyValueList: append($propertyValueList, $propertyValue);
               } @else if ($i == ($breakPointsPair - 1)) {
                  // Обработка последней записи в результат
                  @if ($slope > 0) {
                     $propertyValue: #{"max(" + rem($y1) + ", " + $flyValue + ")"};
                  } @else {
                     $propertyValue: #{"min(" + rem($y1) + ", " + $flyValue + ")"};
                  }
                  $propertyValueList: append($propertyValueList, $propertyValue);
               } @else {
                  // Последующие записи в результат
                  $propertyValue: #{"clamp(" + rem(min($y1, $y2)) + ", " + $flyValue + ", " + rem(max($y1, $y2)) + ")"};
                  $propertyValueList: append($propertyValueList, $propertyValue);
               }
            }
         }
         // Формирование результата работы
         $propertyValue: nth($propertyValueList, 1);
         @if (length($propertyValueList) != 1) {
            @for $i from 2 through length($propertyValueList) {
               $propertyValue: #{$propertyValue + " + " + nth($propertyValueList, $i)};
            }
         }
         @if ($cumul > 0) {
            $propertyValue: #{"calc(" + $propertyValue + " - " + rem(max($cumul, -$cumul)) + ")"};
         } @else if($cumul < 0) {
            $propertyValue: #{"calc(" + $propertyValue + " + " + rem(max($cumul, -$cumul)) + ")"};
         }
         #{$property}: $propertyValue;
      }
   }
}

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

Всем удачи и успехов.

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


  1. Jack_Rabbit
    18.01.2022 11:55

    Это все чудесно, но зачем? Качество кода определяется не количеством строчек, а в первую очередь его читаемостью и простотой.

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

    Что касается самой задачи, то можно использовать для этого CSS переменные (CSS custom properties) и менять их значение в зависимости от ширины экрана или любых других условий. Таким образом вы можете более гибко настраивать поведение кучи свойств в одном месте и вам не придется каждый раз писать длинную строку со значениями.


    1. ryevych Автор
      18.01.2022 14:51

      Минусы понятны, я об этом писал и полностью согласен с вами. Хотелось показать, что такое "в принципе" возможно.


      А насчёт решения с помошью CSS- переменных, разве в таком случае медиа-запросы не возникнут (хотя бы на этапе инициализации переменных)? Плюс проблемы с анимацией свойств. Здесь, лично для меня, фишка именно в том, что поведение реализуется без медиа-запросов вообще.