Однажды я установил приложение по текущему проекту на свой смартфон и заметил, что на некоторых экранах изменилась вёрстка: «поехало» отображение текста, хотя при работе с эмулятором всё было нормально.
Меня зовут Даниль Галимзянов, я начинающий 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)
PackRuble
00.00.0000 00:00Это не совсем обычная функция и проблемы с производительностью действительно имеют место быть. Ознакомьтесь с данным issue:
make sure that int.clamp and double.clamp are optimised in AOT
avdosev
00.00.0000 00:00В целом, да, вы правы у clamp есть проблемы в текущей реализации, но у меня возникают сомнения, что они настолько серьезны, чтобы отказываться от использования этой функции в участках кода которые нечасто вызываются. В случае же когда у нас есть анимация, то это действительно может оказаться заметной проблемой.
Но опять же, этот issue решает проблему с производительностью на уровне sdk языка и на мой взгляд, оно оказывается существенным в случае производительности например всего flutter, в случае же одного вызова разница в производительности будет на грани погрешности (а так и будет, так как textScaleFactor очень редко меняется).
PackRuble
Спасибо, понятная и простая статья. Ради разнообразия скажу, что ещё вот таким образом можно изменить не используя
context
:Всё это сразу можно положить туда, где у вас маршрутизация.
clamp
стоит использовать только когда есть запас по производительности.avdosev
> clamp стоит использовать только когда есть запас по производительности.
В чем проблема clamp? это же обычная функция для типа num, и имеет достаточно тривиальную логику, которая не должна сказаться на производительности всего приложения.
PackRuble
Ошибся с веткой ответа :(
xuxumba
Спасибо за комментарий! Я лично не знал про такой способ задания textScaleFactor.