Как не «сломать» вёрстку Flutter-приложения из-за textScaleFactor

от автора

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

Меня зовут Даниль Галимзянов, я начинающий 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 — в нашем телеграм-канале.

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


ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/720098/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *