Делаем отзывчивый и максимально возможный размер шрифта динамического текста относительно контейнера

в 10:50, , рубрики: css, font, html, javascript, markdown, markup, react.js, ReactJS, responsive, дизайн

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

Предисловие

Для простоты, все условия задачи, реализация, способы применения решение и остальные не относящиеся к делу детали (такие, как стили) будут максимально упрощены, для того, что б сконцентрироваться на требуемом результате. Как и где правильно применить данное решение, зависит только от вас и вашей ситуации. Приведенные ниже примеры кода будут с использованием React и TypeScript, но их знание совсем не обязательно, знаний нативного JavaScript будет вполне чем достаточно.


Задача

Представьте карточку или баннер, в котором мы должны отображать текст, получаемый с бекенда. Мы не знаем заранее какой длинны будет текст, но мы хотим сделать размер шрифта максимально возможным. Что бы текст был не мелким и читаемым (в случае если это будет всего лишь одно слово), и для того, что б он не вылазил за рамки своего контейнера (в случае если слов будет множество). Помимо этого, мы заранее не знаем размеров контейнера, в котором будет располагаться наш текст. Он может быть фиксированным, например с заранее заданными размерами ширины и высоты каким-либо администратором в CMS системе, или же наоборот, это может быть полностью отзывчивый контейнер, который расстанется на всю ширину и высоту родителя. Для более реального кейса, мы будем делать баннер с двумя именами состязающихся между собой соперников. Примерно это будет выглядеть следующим образом:

Готовый баннер.
Готовый баннер.
Демонстрация работы (нажмите на картинку для анимации).
Демонстрация работы (нажмите на картинку для анимации).

Разметка баннера максимально простая, это контейнер содержащий в себе 3 элемента - текст №1, картинка и текст №2 которые будут располагаться друг над другом. Внутри каждого текстового контейнера будет располагаться тег <p>.

<div className="banner">
  <div className="banner__text-container">
    <p className="banner__text banner__text_1">{TEXT_1}</p>
  </div>
  <img className="banner__img" src={icon} />
  <div className="banner__text-container">
    <p className="banner__text banner__text_2">{TEXT_2}</p>
  </div>
</div>

Возможные варианты, которые были отброшены

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

  1. Media queries - Первое, что пришло в голову - это использование медиа выражений. Эта идея отпала быстро по причине динамической длинны текста, и огромного количества всевозможных комбинаций.

  2. clamp() - Неплохой и довольно-таки гибкий способ, который в большинстве случаев может сгодиться. На эту тему даже есть неплохая статья: Linearly Scale font-size with CSS clamp() Based on the Viewport с рабочей "песочницей", где можно просчитать необходимые для себя размеры. Но, "поигравшись" с данной песочницей, и перепробовав различные варианты (строго установленные размеры баннера, или наоборот никаких размеров), возникали случаи, где текст ломался и выходил за рамки.

  3. SVG <text> - Широко рекомендуемый вариант, но заметно прожорливый при ресайзинге и довольно сложный в настройке, в случае когда мы мало что знаем о размерах (длинна текста, размеры контейнера).

  4. Сторонние библиотеки и плагины - этот вариант был отброшен сразу, что б не засорять проект лишними зависимостями.

  5. Подсчёт количества букв - Была и такая безумная идея, вычислять размеры контейнера в котором находится текст, подсчитывать количество букв получаемого текста, и на основании этого делать расчёты размера шрифта и возможного количества строк. Сложность в вычислениях, входящий текст может быть на разных языках (проблема подсчёта букв), и с различными символами (проблема поиска целого слова).


Решение

Логика решения довольно проста. И вкратце состоит из нескольких шагов:

  1. Находим необходимые текстовые узлы.

  2. Вычисляем размеры родительского контейнера (высоту и ширину).

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

Последний пункт звучит немного "костыльным", как будто мы пытаемся методом тыка попасть в нужную нам точку. Отчасти так и есть, но мы не пытаемся глупо добавлять по 1px, а делаем это более элегантным путём, что б сократить количество ненужных вычислений и уменьшить нагрузку.


Структура

Структура максимально простая и понятная с использованием npx create-react-app. В некоторых местах используются стили, для более приятного визуального восприятия.

├── package-lock.json
├── package.json
├── public
│   └── index.html
├── src
│   ├── App.css
│   ├── App.tsx
│   ├── components
│   │   ├── Banner
│   │   │   ├── Banner.scss
│   │   │   └── Banner.tsx
│   ├── constants
│   │   └── index.ts
│   ├── hooks
│   │   ├── useStretchingText.ts
│   ├── index.css
│   ├── index.js
│   └── static
│       └── vs.png
└── tsconfig.json

Описание решения

Пройдёмся в целом по структуре проекта и реализации решения.

В корневом компоненте App.tsx расположен главный Banner.tsx, который циклом проходится по переменной OPPONENTS отрисовывая значения различной длинны для наглядности.

import React from "react";

import "./App.scss";

import { Banner } from "./components/Banner/Banner";

import { OPPONENTS } from "./constants";

export const App = () => {
  return (
    <div className="app">
      {OPPONENTS.map((banner, index) => (
        <Banner key={index} {...banner} />
      ))}
    </div>
  );
};

Основной Banner.tsx также максимально прост, в нём содержится простая разметка, которая содержит имя первого оппонента, иконку, и имя второго оппонента. И как уже можно заметить, функцию которая и отвечает за отзывчивость текста, но об этом немного попозже.

import "./Banner.scss";

import { useStretchingText } from "../../hooks/useStretchingText";

const icon = require("../../static/vs.png");

export const Banner = ({ text_1, text_2 }) => {
  useStretchingText("banner__text");

  return (
    <div className="banner">
      <div className="banner__text-container">
        <p className="banner__text banner__text_1">{text_1}</p>
      </div>
      <img className="banner__img" src={icon} />
      <div className="banner__text-container">
        <p className="banner__text banner__text_2">{text_2}</p>
      </div>
    </div>
  );
};

Немного о стилях Banner.scss, в большинстве своём они тут всего лишь для красоты, но некоторые моменты я бы хотел объяснить.

  1. Размеры иконки заданы явно для простоты.

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

  3. Размеры каждого текстового контейнера (где располагается тег <p> c именем оппонента) вычисляются равномерно, вычитая размеры картинки, паддинги.

Стили
// Variables.
$banner_width: 100px;
$banner_height: 300px;
$banner_padding: 16px;
$icon_width: 100px;
$icon_height: 100px;
$color_grandis: #fed480;
$color_shakespeare: #45a7cd;
$color_sandy_brown: #f59977;

// Banner icon.
.banner__img {
  width: $icon_width;
  height: $icon_height;
}

// Banner.
.banner {
  height: $banner_height;
  padding: $banner_padding;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  border: 5px solid;
  border-image-slice: 1;
  border-image-source: linear-gradient(to left, #fed480, #45a7cd);
  border-color: #45a7cd #45a7cd #fed480 #fed480;
}

// Banner text container.
.banner__text-container {
  width: 100%;
  height: calc( (#{$banner_height} / 2) - (#{$icon_height / 2}) - #{$banner_padding} );
}

// Banner text.
.banner__text {
  font-weight: 900;
  text-align: center;
  word-break: break-word;
  text-shadow:
          1.5px 0px 0px black,
          -1.5px 0px 0px black,
          0px 1.5px 0px black,
          0px -1.5px 0px black;
}

.banner__text_1 {
  color: $color_shakespeare;
}

.banner__text_2 {
  color: $color_grandis;
}

Далее начинается самое интересное, реализация решения вынесена в кастомный хук (для тех кто не работал с React - воспринимайте это как обычную функцию) useStretchingText.ts . Буду проходиться по нему построчно и объяснять что там происходит. Хук принимает два аргумента:

  1. textClassName - Это класс, по которому мы будем находить необходимый текст.

  2. initialMinFontSize - Изначально минимально допустимый размер шрифта. На случай если мы не хотим допустить значение ниже установленного.

Инициализируем переменные которые будем использоваться позже:

let fontSize: number,
  maxHeight: number,
  maxWidth: number,
  parentElement: HTMLElement,
  maxCycles: number = 100;

Находим текстовые ноды по указанному классу:

const textElements: NodeList = document.querySelectorAll(
.${textClassName}
);

Делаем проверку на наличие найденных текстовых узлов, и проходимся по каждому из них:

if (textElements.length) {
textElements.forEach((element) => {
// Some logic...
});
}

Делаем некоторые вычисления, на основании родительского контейнера (с классом .banner__text-container), рассчитываем максимально возможные размеры контейнера с текстом (с классом .banner__text), устанавливаем минимальный и максимальный размеры шрифта. Для ясности хочу сказать, что в условиях задачи нам нужно найти максимальный размер шрифта, но в логике решения мы будем отталкиваться от минимального значения. То есть, не ниже которого, наши требования не будут удовлетворены. После получение значений, применяем стиль к текстовому узлу.

parentElement = element.parentElement;
maxWidth = parentElement.clientWidth;
maxHeight = parentElement.clientHeight;
fontSize = maxHeight;
let minFontSize = initialMinFontSize;
let maxFontSize = fontSize;
element["style"].fontSize = ${fontSize}px;

После того как мы применили первый размер шрифта, происходит вычисление и проверка, удовлетворяет ли текущее значение наши требования, и так до тех пор, пока не будет найдено оптимальное значение. Пару слов о происходящем: Цикл продолжается до тех пор, пока текущий размер шрифта не равняется минимально удовлетворяющему размеру шрифта. Если высота и ширина текстового узла (с классом .banner__text) меньше или равны максимально допустимым, значит мы меняем значение minFontSize, если же наоборот, мы вылезли за допустимые рамки, мы меняем значение maxFontSize. После чего мы рассчитываем новое вероятно подходящее для нас значение размера шрифта. Но, что б не делать лишних вычислений, прибавляя по 1px, мы делаем это по следующей формуле: fontSize=Math.floor((minFontSize + maxFontSize) / 2);

В дополнении к этому, мы обезопасили себя заранее с помощью переменной, которую мы объявляли выше maxCycles: number = 100;. Чтобы по каким-либо причинам не "провалиться" в бесконечный цикл, мы прервём вычисления, достигнув установленного лимита.

while (fontSize !== minFontSize) {
  element["style"].fontSize = `${fontSize}px`;

  if (
    element["offsetHeight"] <= maxHeight &&
    element["offsetWidth"] <= maxWidth
  ) {
    minFontSize = fontSize;
  } else {
    maxFontSize = fontSize;
  }

  fontSize = Math.floor((minFontSize + maxFontSize) / 2);

  --maxCycles;
  if (maxCycles <= 0) {
    console.error("The maximum cycle exceeded");
    break;
  }
}

Ниже на анимации показано, что происходит если задать слишком маленькое занчение для maxCycles: number = 100;. Это не сломает работу, но также и не даст нам зависнуть на бесконечных вычислениях.

Маленькое значение maxCycles (нажмите на картинку для анимации).
Маленькое значение maxCycles (нажмите на картинку для анимации).

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

element["style"].fontSize = ${minFontSize}px;

Ещё один небольшой приём оптимизации, с помощью использования функции throttle, библиотеки Lodash. С её помощью, мы будем ограничивать максимальное количество вызовов функции в заданный интервал. Я использую значение 15, так как где-то встречал информацию, что браузеры производят перерисовку контента страницы на ранее чем каждые 16 миллисекунд.

const debouncedFunction = throttle(stretchingText, 15);

Если установить слишком большое значение для throttle функции (в нашем случае 100 мс уже является большим значением), то будут явно заметны задержки в работе:

Большое значение throttle (нажмите на картинку для анимации).
Большое значение throttle (нажмите на картинку для анимации).

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

P.S. Навешивание прослушивателя таким образом является не совсем хорошим решением и в данном случае и сделано исключительно для показания работоспособности.

useEffect(() => {
    window.addEventListener("resize", debouncedFunction);
    debouncedFunction();

    return () => {
      window.removeEventListener("resize", debouncedFunction);
    };
  }, []);

Конечный результат готового хука выглядит так:

import { useEffect } from "react";
import { throttle } from "lodash";

export const useStretchingText = (
  textClassName: string,
  initialMinFontSize = 3
): void => {
  const stretchingText = () => {
    let fontSize: number,
      maxHeight: number,
      maxWidth: number,
      parentElement: HTMLElement,
      maxCycles: number = 50;

    const textElements: NodeList = document.querySelectorAll(
      `.${textClassName}`
    );

    if (textElements.length) {
      textElements.forEach((element) => {
        parentElement = element.parentElement;
        maxWidth = parentElement.clientWidth;
        maxHeight = parentElement.clientHeight;
        fontSize = maxHeight;

        let minFontSize = initialMinFontSize;
        let maxFontSize = fontSize;

        element["style"].fontSize = `${fontSize}px`;

        while (fontSize !== minFontSize) {
          element["style"].fontSize = `${fontSize}px`;

          if (
            element["offsetHeight"] <= maxHeight &&
            element["offsetWidth"] <= maxWidth
          ) {
            minFontSize = fontSize;
          } else {
            maxFontSize = fontSize;
          }

          fontSize = Math.floor((minFontSize + maxFontSize) / 2);

          --maxCycles;
          if (maxCycles <= 0) {
            console.error("The maximum cycle exceeded");
            break;
          }
        }

        element["style"].fontSize = `${minFontSize}px`;
      });
    }
  };

  const debouncedFunction = throttle(stretchingText, 15);

  useEffect(() => {
    window.addEventListener("resize", debouncedFunction);
    debouncedFunction();

    return () => {
      window.removeEventListener("resize", debouncedFunction);
    };
  }, []);
};

Демонстрация работы:

Демонстрация работы (нажмите на картинку для анимации).
Демонстрация работы (нажмите на картинку для анимации).

Песочница:

Что можно было бы улучшить

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

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

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

  4. Ограничения размеров. У нас уже есть минимально допустимый размер шрифта, как опциональный второй аргумент initialMinFontSize. Вполне вероятно, что кому-то понадобится также добавить максимально допустимое значение, на случай если текст может состоять всего из одного слова, а сам баннер будет растянут на весь экран.

  5. Согласованность. В данный момент, каждый текстовый узел рассчитывает свои стили отдельно. Это значит, что каждый текст будет иметь свой размер шрифта. И если на примере нашего баннера имя первого оппонента будет состоять всего из одного слова, а имя второго из множества слов, разница в размере шрифта может быть значительной. Это не минус, а всего лишь один из способов решения. Возможно кому-то нужно иметь единый размер шрифта для всех оппонентов баннера (основываясь на меньшем), а кому-то наоборот, каждый оппонент должен иметь свой размер шрифта. Это всего лишь предложение, которое стоит взять во внимание.


Полезные материалы

  1. Код проекта: GitHub.

  2. Онлайн песочница: Codesandbox.

Автор: Александр Ткаченко

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js