Однажды я установил приложение по текущему проекту на свой смартфон и заметил, что на некоторых экранах изменилась вёрстка: «поехало» отображение текста, хотя при работе с эмулятором всё было нормально. 

Меня зовут Даниль Галимзянов, я начинающий Flutter-разработчик в компании Surf. Разобрался, в чём причина проблемы с вёрсткой текста, и хочу поделиться с вами.

Проблема наглядно

Давайте посмотрим на код виджета MyWidget из этого gist в DartPad. 

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  double _deviceTsf = 1.0;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      builder: (context, child) {
        return MediaQuery(
          data: MediaQuery.of(context).copyWith(textScaleFactor: _deviceTsf),
          child: child ?? const SizedBox.shrink(),
        );
      },
      home: Scaffold(
        appBar: AppBar(
          title: Text(
            'Text Scale Factor на устройстве = $_deviceTsf',
            textScaleFactor: 1.0,
          ),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const SizedBox(height: 40),
              const Text(
                'Типа настройки размера шрифта в Accessibility',
                textScaleFactor: 1.0,
              ),
              Slider(
                value: _deviceTsf,
                min: 0.85,
                max: 1.3,
                divisions: 3,
                onChanged: (value) {
                  setState(() {
                    _deviceTsf = value;
                  });
                },
              ),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 10),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: const [
                    Text('0.85', textScaleFactor: 1.0),
                    Text('1.00', textScaleFactor: 1.0),
                    Text('1.15', textScaleFactor: 1.0),
                    Text('1.30', textScaleFactor: 1.0),
                  ],
                ),
              ),
              Flexible(child: MyWidget(deviceTsf: _deviceTsf)),
            ],
          ),
        ),
      ),
    );
  }
}

class MyWidget extends StatelessWidget {
  final double deviceTsf;
  const MyWidget({required this.deviceTsf, super.key});

  @override
  Widget build(BuildContext context) {
    const defaultTextSize = 25;
    final defaultTextStyle = TextStyle(
      fontSize: defaultTextSize.toDouble(),
      color: Colors.black,
    );
    const additionalTextSpans = <TextSpan>[
      TextSpan(
        text: ' widget ',
        style: TextStyle(fontStyle: FontStyle.italic),
      ),
      TextSpan(
        text: '- size $defaultTextSize',
        style: TextStyle(fontWeight: FontWeight.bold),
      )
    ];

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(
          'Text widget - size $defaultTextSize',
          textAlign: TextAlign.center,
          style: defaultTextStyle, // Заданный стиль текста высотой 25
        ),
        const SizedBox(height: 20),
        Text.rich(
          textAlign: TextAlign.center,
          TextSpan(
            text: 'Text.rich',
            style: defaultTextStyle, // Заданный стиль текста высотой 25
            children: additionalTextSpans,
          ),
        ),
        const SizedBox(height: 20),
        RichText(
          textAlign: TextAlign.center,
          text: TextSpan(
            text: 'RichText',
            style: defaultTextStyle, // Заданный стиль текста высотой 25
            children: additionalTextSpans,
          ),
        ),
        const SizedBox(height: 20),
        Text(
          deviceTsf != 1 ? '????' : '????',
          style: const TextStyle(fontSize: 40),
        ),
      ],
    );
  }
}

Вроде бы размер текста задан везде одинаковый: он равен 25. Но когда меняется масштабирование текста, отображение получается разное. Хм…

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

Почему так получилось

Дело было в настройках масштабирования текста на моём смартфоне, а если точнее — в свойстве textScaleFactor, которое есть у виджетов Text и RichText. Для упрощения будем называть его TSF.

TSF — один из Accessibility-параметров, который задается пользователем. Это количество отображаемых пикселей на экране для каждого логического пикселя. У пользователя эти настройки находятся в соответствующем разделе настроек смартфона.

Например, если TSF равен 1,5, текст на экране будет на 50% больше указанного размера шрифта fontSize.

Как приложение работает с TSF

Приложение получает значение TSF из системы. Доступ к нему можно получить при помощи класса MediaQuery, который хранит в себе MediaQueryData с нужным нам параметром.

MaterialApp, CupertinoApp и WidgetsApp по умолчанию добавляют MediaQuery в дерево. Статический метод of получает по контексту ближайший InheritedWidget типа MediaQuery и возвращает из него свойство data типа MediaQueryData:

final textScaleFactor = MediaQuery.of(context).textScaleFactor;

Также можно воспользоваться статическим методом textScaleFactorOf, который либо вернет текущий TSF на устройстве, либо 1.0, если MediaQuery не был определен выше по дереву.

final textScaleFactor = MediaQuery.textScaleFactorOf(context);

Как текстовые виджеты работают с TSF

Для виджета Text всё просто

Есть три варианта, откуда виджет Text получает значения:

  • Значение из MediaQuery — то есть заданное пользователем на смартфоне.

  • Значение, заданное разработчиком.

  • Значение по умолчанию.

А вот у RichText поле textScaleFactor ведёт себя иначе

Если его не передали, оно по умолчанию становится равным 1.0 и не изменится в зависимости от настроек смартфона, поскольку не подписано на изменения MediaQuery

Эту проблему решает использование именного конструктора Text.rich, который предоставляет возможности RichText.

Как подготовиться к тому, что пользователь может изменить масштаб текста

Значения TSF зависят от операционной системы и модели телефона. Они могут варьироваться в диапазоне примерно от 0.8 до 3.0: разброс серьёзный и надо быть к этому готовым. 

Что можно сделать:

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

  • Если диапазон определён, можно добавить на дебаг-экран возможность менять значение TSF в заданных пределах, чтоб отслеживать изменения в вёрстке во время отладки и тестирования. 

Код реализации установки ограничений для TSF

const maxPossibleTsf = 1.1;

return MaterialApp(
 builder: (context, child) {
   final data = MediaQuery.of(context);
   final newTextScaleFactor = min(maxPossibleTsf, data.textScaleFactor);
   // можно так, если нам надо задать минимальное и
   // максимальное значение:
   // data.textScaleFactor.clamp(minPossibleTsf, maxPossibleTsf);
   return MediaQuery(
     data: data.copyWith(
       textScaleFactor: newTextScaleFactor,
     ),
     child: child ?? const SizedBox.shrink(),
   );
 },
 home: const Text('We are finally ready for any TSF ????'),
);

Больше полезностей о работе с Flutter, а также вакансии, новости, кейсы из практики Surf — в нашем телеграм-канале.

Присоединяйтесь >>

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


  1. PackRuble
    00.00.0000 00:00

    Спасибо, понятная и простая статья. Ради разнообразия скажу, что ещё вот таким образом можно изменить не используя context:

    MediaQuery(
      data: MediaQueryData.fromWindow(
         WidgetsBinding.instance.window
      ).copyWith(textScaleFactor: 1),
      child: ...,
    ),
    

    Всё это сразу можно положить туда, где у вас маршрутизация.
    clamp стоит использовать только когда есть запас по производительности.


    1. avdosev
      00.00.0000 00:00

      > clamp стоит использовать только когда есть запас по производительности.

      В чем проблема clamp? это же обычная функция для типа num, и имеет достаточно тривиальную логику, которая не должна сказаться на производительности всего приложения.


      1. PackRuble
        00.00.0000 00:00

        Ошибся с веткой ответа :(


    1. xuxumba
      00.00.0000 00:00
      +1

      Спасибо за комментарий! Я лично не знал про такой способ задания textScaleFactor.


  1. PackRuble
    00.00.0000 00:00

    Это не совсем обычная функция и проблемы с производительностью действительно имеют место быть. Ознакомьтесь с данным issue:

    make sure that int.clamp and double.clamp are optimised in AOT


    1. avdosev
      00.00.0000 00:00

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


      Но опять же, этот issue решает проблему с производительностью на уровне sdk языка и на мой взгляд, оно оказывается существенным в случае производительности например всего flutter, в случае же одного вызова разница в производительности будет на грани погрешности (а так и будет, так как textScaleFactor очень редко меняется).