Flutter: рисуем текст вдоль окружности
Понадобилось мне тут на днях в одном мини-проекте (проект, можно сказать, экспериментальный, сделан на Flutter Web) реализовать такого вида штуку:
Собственно, код для мобильных платформ и для веба один и тот же, поэтому для удобства скриншоты сделаны с мобильного приложения.
Проблема в том, что Flutter не поддерживает написание текста вдоль path (и вряд ли будет в ближайшее время). Поэтому решено было попробовать реализовать это вручную (благо, нам нужна была поддержка только одного частного случая — рисование текста вдоль арки). Одним из способов реализации я и хочу с вами поделиться.
Для реализации достаточно школьного курса геометрии (которую, как оказалось к моему стыду, я уже успел порядком подзабыть), и хотя это не самый оптимальный вариант, его вполне достаточно для нашего use-case.
Итак, для начала определим интерфейс нашего виджета (назовем его ArcText
):
class ArcText extends StatelessWidget {
const ArcText({
Key key,
@required this.radius,
@required this.text,
@required this.textStyle,
this.startAngle = 0,
}) : super(key: key);
final double radius;
final String text;
final double startAngle;
final TextStyle textStyle;
}
Здесь startAngle
определяет начальный угол, от которого будет отрисовываться текст, и radius
— радиус арки, вдоль которой этот текст будет размещаться. Виджет мы сможем использовать, например, так:
Container(
decoration: BoxDecoration(
border: Border.all(),
color: Colors.white,
),
width: 300,
height: 300,
child: ArcText(
radius: 100,
text: 'Hello, Habr! I am ArcText widget. I can draw circular text.',
textStyle: TextStyle(fontSize: 18, color: Colors.black),
startAngle: -pi / 2,
),
)
Для отрисовки текста мы будем использовать CustomPainter
, все магия будет происходить в нем:
class ArcText extends StatelessWidget {
const ArcText({
Key key,
@required this.radius,
@required this.text,
@required this.textStyle,
this.startAngle = 0,
}) : super(key: key);
final double radius;
final String text;
final double startAngle;
final TextStyle textStyle;
@override
Widget build(BuildContext context) => CustomPaint(
painter: _Painter(
radius,
text,
textStyle,
initialAngle: startAngle,
),
);
}
Перед тем, как реализовывать класс _Painter
, давайте определимся, как это будет работать. Схематично я это изобразил на следующем рисунке:
Суть в том, что мы будем размещать каждую букву на хорде окружности, соответствующей заданному радиусу. d
— длина этой хорды — соответствует ширине буквы. Соответственно, после каждой буквы мы поворачиваем холст на определенный угол и сдвигаем на расстояние d
(холст трансформировать удобнее, чем вычислять координаты).
Итак, приступим к реализации класса _Painter
:
class _Painter extends CustomPainter {
_Painter(this.radius, this.text, this.textStyle, {this.initialAngle = 0});
final num radius;
final String text;
final double initialAngle;
final TextStyle textStyle;
@override
void paint(Canvas canvas, Size size) {}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
Для отрисовки нам понадобятся: радиус, сам текст, стиль, из которого мы получим ширину каждой буквы, и начальный угол. Метод shouldRepaint
определяет, надо ли вызывать метод paint
, и в простейшем случае (если нет каких-то сложных вычислений), может всегда возвращать true
.
Следуя нашей логике, начнем реализацию метода paint
:
@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
canvas.drawCircle(Offset.zero, radius, Paint()..style=PaintingStyle.stroke);
canvas.translate(0, -radius);
}
Здесь мы изначально двигаем холст, чтобы центр окружности пришелся в центр контейнера, рисуем окружности (просто для наглядности, потом мы ее уберем), и еще смещаем холст на расстояние -radius
по оси Y (это нужно для того, чтобы в дальнейшем удобно было вращать и сдвигать холст). Мы получим такую картину:
Добавим отрисовку букв:
@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
canvas.drawCircle(Offset.zero, radius, Paint()..style=PaintingStyle.stroke);
canvas.translate(0, -radius);
double angle = 0;
for (int i = 0; i < text.length; i++) {
angle = _drawLetter(canvas, text[i], angle);
}
}
double _drawLetter(Canvas canvas, String letter, double prevAngle) {
_textPainter.text = TextSpan(text: letter, style: textStyle);
_textPainter.layout(
minWidth: 0,
maxWidth: double.maxFinite,
);
final double d = _textPainter.width;
final double alpha = 2 * math.asin(d / (2 * radius));
final newAngle = _calculateRotationAngle(prevAngle, alpha);
canvas.rotate(newAngle);
_textPainter.paint(canvas, Offset(0, -_textPainter.height));
canvas.translate(d, 0);
return alpha;
}
double _calculateRotationAngle(double prevAngle, double alpha) =>
(alpha + prevAngle) / 2;
Как я уже говорил, идея проста: с каждой буквой мы поворачиваем холст на определенный угол, чтобы хорда текущей буквы стала параллельной оси X, рисуем букву, и сдвигаем холст по оси X на ширину этой буквы. Школьный курс геометрии нам нужен, чтобы найти угол, соответствующий этой хорде, и новый угол, на который нужно повернуть холст.
Из формулы длины хорды:
мы можем получить формулу для расчета угла:
Угол же, на который надо повернуть холст, вычисляется очень просто:
где ⍺
— центральный угол предыдущей хорды, β
— центральный угол новой хорды.
Получаем такую картину:
Теперь нам осталось учесть начальный угол и убрать отрисовку направляющего круга:
@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2 - radius);
if (initialAngle != 0) {
final d = 2 * radius * math.sin(initialAngle / 2);
final rotationAngle = _calculateRotationAngle(0, initialAngle);
canvas.rotate(rotationAngle);
canvas.translate(d, 0);
}
double angle = initialAngle;
for (int i = 0; i < text.length; i++) {
angle = _drawLetter(canvas, text[i], angle);
}
}
Итого, полный код класса выглядит так:
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class ArcText extends StatelessWidget {
const ArcText({
Key key,
@required this.radius,
@required this.text,
@required this.textStyle,
this.startAngle = 0,
}) : super(key: key);
final double radius;
final String text;
final double startAngle;
final TextStyle textStyle;
@override
Widget build(BuildContext context) => CustomPaint(
painter: _Painter(
radius,
text,
textStyle,
initialAngle: startAngle,
),
);
}
class _Painter extends CustomPainter {
_Painter(this.radius, this.text, this.textStyle, {this.initialAngle = 0});
final num radius;
final String text;
final double initialAngle;
final TextStyle textStyle;
final _textPainter = TextPainter(textDirection: TextDirection.ltr);
@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2 - radius);
if (initialAngle != 0) {
final d = 2 * radius * math.sin(initialAngle / 2);
final rotationAngle = _calculateRotationAngle(0, initialAngle);
canvas.rotate(rotationAngle);
canvas.translate(d, 0);
}
double angle = initialAngle;
for (int i = 0; i < text.length; i++) {
angle = _drawLetter(canvas, text[i], angle);
}
}
double _drawLetter(Canvas canvas, String letter, double prevAngle) {
_textPainter.text = TextSpan(text: letter, style: textStyle);
_textPainter.layout(
minWidth: 0,
maxWidth: double.maxFinite,
);
final double d = _textPainter.width;
final double alpha = 2 * math.asin(d / (2 * radius));
final newAngle = _calculateRotationAngle(prevAngle, alpha);
canvas.rotate(newAngle);
_textPainter.paint(canvas, Offset(0, -_textPainter.height));
canvas.translate(d, 0);
return alpha;
}
double _calculateRotationAngle(double prevAngle, double alpha) =>
(alpha + prevAngle) / 2;
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}