Flutter: рисуем текст вдоль окружности

  • Tutorial

Понадобилось мне тут на днях в одном мини-проекте (проект, можно сказать, экспериментальный, сделан на 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 на ширину этой буквы. Школьный курс геометрии нам нужен, чтобы найти угол, соответствующий этой хорде, и новый угол, на который нужно повернуть холст.


Из формулы длины хорды:


${\displaystyle d=2r\sin {\frac {\alpha }{2}}}$


мы можем получить формулу для расчета угла:


$\alpha=2\arcsin({\frac d {2r}})$


Угол же, на который надо повернуть холст, вычисляется очень просто:


$\Delta=(\frac{\alpha+\beta} 2)$


где – центральный угол предыдущей хорды, β – центральный угол новой хорды.


Получаем такую картину:



Теперь нам осталось учесть начальный угол и убрать отрисовку направляющего круга:


@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;
}

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 6

    +1
    Полярные координаты.
      0

      А что "полярные координаты"? Как бы они помогли конкретно в этом случае? Принцип-то был бы тот же самый – сдвинуть холст в точку (x,y), повернуть на угол alpha и нарисовать букву. Что из этого стало бы проще в полярных координатах?

      0
      Было-бы хорошо, если вы оформили ваш код в плагин и отправили на pub.
        0

        Мне кажется, для плагина тут слишком мало функциональности – слишком уж узкий случай решается. Я не сторонник "npm" подхода, когда на каждый left-pad тянется отдельная зависимость :)

          0
          Добавить возможность детальной кастомизации, и прибавиться функциональность, и никто не мешает в будущем дополнять плагин новыми фишками ;)
          del, нашел аналог
            +1

            Ну, статью на английском я скоро тоже выложу; но, пожалуй, Вы правы, залью на pub.


            del, нашел аналог

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

      Only users with full accounts can post comments. Log in, please.