Привет, Хабр!
Многие во Flutter привыкли собирать интерфейс из виджетов, не задумываясь, как они вообще устроены. Действительно, стандартных виджетов хватает почти на всё. Почти. Иногда возникает задача, где готовых решений нет или их производительности недостаточно. В такие моменты хочется залезть глубже в движок Flutter и написать что‑то своё на уровне рендеринга. Звучит немного страшненько, но я посмотрим, как сделать собственный RenderObject (конкретно RenderBox) с нуля.
Зачем лезть в RenderObject?
Вы можете спросить: «А действительно, зачем? Ведь есть CustomPaint, кастомные виджеты… Зачем писать свой RenderBox?» Отвечаю по порядку.
Случаи, когда пригодится свой RenderObject:
Необычные раскладки: если надо реализовать очень нестандартный расположение дочерних элементов, которого нет готового.
Особая отрисовка: многое можно нарисовать через
CustomPaint, но RenderObject даёт полный контроль над Canvas и над фазой компоновки.Производительность: создавать тысячу объектов
Widget/Element/RenderObjectнакладно. Можно хитрить, нарисовать их все в одном CustomPaint или реализовать один RenderObject, который сам отрисует коллекцию фигур.Любопытство.
Однако, кастомный RenderObject вещь более сложная, чем обычный StatefulWidget. Придётся вручную обрабатывать layout, рисование, возможно, события. Код станет более низкоуровневым. Поэтому не стоит без необходимости усложнять себе жизнь.
Кратко о Rendering Pipeline Flutter
Чтобы уверенно писать RenderObject, надо понимать, как Flutter отображает виджеты на экране.
Widget декларация интерфейса (как конфигурация). Вы описываете, что хотите увидеть. Но сами по себе виджеты ничего не отображают.
Element связующее звено, связывает виджет с RenderObject и хранит состояние (для StatefulWidgets).
RenderObject непосредственный «отрисовщик» или «боксы» на экране. Они знают свой размер, положение и умеют рисовать себя (и своих детей, если есть).
Каждый раз, когда Flutter собирает UI, он создаёт/обновляет виджеты, которые создают элементы, а те в свою очередь управляют RenderObject. Рендер объекты образуют дерево рендеринга, которое уже рисуется в окне.
В Flutter есть разные подклассы RenderObject для разных задач: RenderBox для 2D‑прямоугольников (большинство обычных виджетов), RenderSliver для эффектов типа списка, который скроллится, и прочие. Мы сосредоточимся на RenderBox, так как это самый понятный случай, элемент с конкретным размером.
Методы, которые нас интересуют при наследовании RenderBox:
performLayout(): рассчитываем размер нашего бокса (RenderBox) на основе входныхconstraints(ограничений по размеру от родителя). Тут же можно вызвать layout у дочерних RenderObject«ов, если они есть, и расположить их.»paint(PaintingContext, Offset): здесь собственно рисование. Нам даютPaintingContextи смещение, по которому наш бокс должен рисоваться, и мы должны вызвать необходимые методы рисования (черезcontext.canvas, например).hitTest(...): метод для обработки кликов/тапов. Если наш элемент интерактивный, нужно переопределить, чтобы он сообщал, когда пользователь нажал на его область.
С hitTest можно пока не заморачиваться, в примере сделаем неинтерактивный прогресс‑бар. По дефолту RenderBox.hasSize и хиттест сам проверит границы.
Создаём свой RenderBox
Реализуем простой прогресс‑бар, который будет рисоваться в виде цветной заполненной полоски. Пусть он будет занимать всю доступную ширину по родительским constraints, а высоту возьмём фиксированную (скажем, 20 пикселей, для наглядности). У него будет свойство progress (0.0 до 1.0), доля заполнения, а также цвета: цвет фона (незаполненная часть) и цвет заполненной части.
Класс RenderProgressBar
Начинаем с создания класса, наследующего RenderBox. Определим нужные поля и переопределим методы:
import 'package:flutter/rendering.dart';
import 'package:flutter/painting.dart';
class RenderProgressBar extends RenderBox {
double _progress;
Color _backgroundColor;
Color _foregroundColor;
RenderProgressBar({
required double progress,
required Color backgroundColor,
required Color foregroundColor,
}) : _progress = progress,
_backgroundColor = backgroundColor,
_foregroundColor = foregroundColor;
// Getters/setters для обновления свойств
double get progress => _progress;
set progress(double value) {
value = value.clamp(0.0, 1.0);
if (_progress == value) return;
_progress = value;
markNeedsPaint(); // при изменении прогресса нужно перерисоваться
}
Color get backgroundColor => _backgroundColor;
set backgroundColor(Color value) {
if (_backgroundColor == value) return;
_backgroundColor = value;
markNeedsPaint();
}
Color get foregroundColor => _foregroundColor;
set foregroundColor(Color value) {
if (_foregroundColor == value) return;
_foregroundColor = value;
markNeedsPaint();
}
@override
void performLayout() {
// Задаём размер прогресс-бара
final double desiredHeight = 20.0;
// Ширину возьмём максимально возможную
double width = constraints.maxWidth;
if (width.isInfinite) {
width = 100.0; // если вдруг неограничено, ставим условные 100px
}
// Высоту возьмём либо заданную, либо минимальную из constraints, либо desiredHeight
double height = desiredHeight;
if (!constraints.hasBoundedHeight) {
// нет жёстких ограничений по высоте
height = desiredHeight;
} else {
// если есть ограничения, впишемся в них
height = constraints.constrainHeight(desiredHeight);
}
// Устанавливаем размер RenderBox
size = Size(constraints.constrainWidth(width), height);
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
final Rect totalRect = offset & size; // прямоугольник всей области прогресс-бара
// Рисуем фон (незаполненная часть)
final Paint backgroundPaint = Paint()..color = _backgroundColor;
canvas.drawRect(totalRect, backgroundPaint);
// Рисуем заполненную часть (foreground), ширина пропорциональна progress
final double filledWidth = size.width * _progress;
if (filledWidth > 0) {
final Rect filledRect = Rect.fromLTWH(offset.dx, offset.dy, filledWidth, size.height);
final Paint foregroundPaint = Paint()..color = _foregroundColor;
canvas.drawRect(filledRect, foregroundPaint);
}
}
}
В performLayout берём constraints и вычисляем размер. Логика такая, по ширине занимаем максимум (это типично для прогресс‑бара — растянуться), по высоте фиксированные ~20px (или constraints.minHeight, если вдруг он задан). Мы аккуратно используем constraints.constrainWidth и constrainHeight, они сами применят min/max ограничения. В редком случае, если maxWidth бесконечен (значит родитель готов дать сколько угодно ширины, например, внутри Row без ограничений), мы поставили заглушку 100. В реальной жизни лучше позволять передавать желаемую ширину через сам виджет, но опустим это для простоты.
В paint уже получаем Canvas и рисуем два прямоугольника: полный фон и поверх, заполненную часть. Тут используем значение progress для вычисления ширины заполнения. Не забываем, progress от 0.0 до 1.0, поэтому сразу умножаем на ширину. Если _progress ноль, вообще не рисуем foreground, чтобы лишнего не делать.
После изменения значений _progress или цветов мы вызываем markNeedsPaint(). Это говорит Flutter, что на следующем кадре надо вызвать paint() у этого объекта. Если бы изменение могло влиять на размер, пришлось бы вызывать markNeedsLayout(), но в нашем случае прогресс/цвет размер не меняют.
Делаем Widget для нашего RenderObject
Сам RenderObject сам по себе в дерево не встроиш, Flutter требует обёртку в виде виджета. Нам нужен виджет, который:
будет хранить публично свойства (progress, colors),
при построении создавать наш
RenderProgressBar,при обновлении (когда Flutter решит обновить виджет) передавать новые значения в уже существующий RenderObject.
Для RenderBox, не имеющего детей, удобно наследоваться от LeafRenderObjectWidget (листового узла). Это упрощает шаблон.
Напишем ProgressBar виджет:
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
// Не StatefulWidget, а наследник от LeafRenderObjectWidget
class ProgressBar extends LeafRenderObjectWidget {
final double progress;
final Color backgroundColor;
final Color foregroundColor;
const ProgressBar({
Key? key,
required this.progress,
this.backgroundColor = const Color(0xFFE0E0E0), // светло-серый по умолчанию
this.foregroundColor = const Color(0xFF2196F3), // синий по умолчанию (материал blue)
}) : super(key: key);
@override
RenderProgressBar createRenderObject(BuildContext context) {
return RenderProgressBar(
progress: progress,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
);
}
@override
void updateRenderObject(BuildContext context, covariant RenderProgressBar renderObject) {
// Обновляем существующий RenderObject новыми значениями
renderObject
..progress = progress
..backgroundColor = backgroundColor
..foregroundColor = foregroundColor;
}
}
Этот виджет можно использовать в приложении как любой другой. Замечу, что мы сделали его Stateless, потому что само состояние progress нам передаётся извне. Если бы у него было внутреннее изменяемое состояние, можно было бы завернуть его в StatefulWidget, но тогда часть логики всё равно легла бы на RenderObject (например, анимация прогресса могла бы вызывать markNeedsPaint).
Используем наш кастомный виджет
Включим наш ProgressBar куда‑нибудь в интерфейс, чтобы убедиться, что он работает. Пример минимального приложения:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
double demoProgress = 0.6;
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Custom RenderObject Demo')),
body: Center(
child: ProgressBar(
progress: demoProgress,
backgroundColor: Colors.grey[300]!, // светло-серый
foregroundColor: Colors.blue, // синий заполнения
),
),
),
);
}
}
Если запустить, увидим по центру экрана горизонтальную серую полоску, заполненную синим на 60%.
Разумеется, можно менять demoProgress (сделать его переменной в StatefulWidget и дергать setState), наш ProgressBar будет реагировать, потому что в updateRenderObject мы присваиваем новое значение progress, которое в свою очередь вызывает markNeedsPaint(). То есть анимацию, например, можно сделать, запуская Timer или AnimationController и обновляя значение.
Расширяем возможности
На примере прогресс‑бара мы освоили базовый шаблон: RenderBox + соответствующий Widget. Дальше дело практики, но есть ещё нюансы, о которых стоит знать:
Обработка кликов: если нужно реагировать на жесты, проще всего обернуть ваш виджет в
GestureDetectorили использовать Listener. Но можно и в самом RenderBox переопределитьhitTestSelf()(если достаточно просто знать, попал ли тап по самому виджету). ВернувtrueизhitTestSelf, вы указываете, что вся площадь RenderBox интерактивна. Дальше наверху в иерархии GestureDetector поймает событие. Если же нужен свой особый распознавание, придётся разбираться сPointerEventи самим их распределять, это уже более редкий случай. В нашем прогресс‑баре интерактивности нет, поэтому мы это пропустили.Несколько детей: мы рассмотрели
LeafRenderObjectWidgetбез детей. Если нужно вписывать детей (например, кастомный Layout виджет, содержащий другие виджеты), тогда ваш RenderObject должен наследовать не просто RenderBox, а классы типаRenderBox with ContainerRenderObjectMixin<RenderBox, ParentDataClass>. Также надо позаботиться о ParentData для детей (например, класс, наследующий ContainerBoxParentData). В общем, можно сделать свой Flex, свой Stack и так далее, если очень хочетсяОптимизация: не забывайте, что при кастомном рендеринге на вас лежит ответственность за лишние вызовы. Например, не вызывайте
markNeedsLayoutбез необходимости, это может заставить Flutter заново пересчитывать весь фрейм. Если меняется только отрисовка, зовитеmarkNeedsPaint. Также при рисовании старайтесь переиспользовать объектыPaint,Pathи др., если они тяжелые в создании. В нашем примере два Paint создаются каждый раз, для такой мелочи это некритично, но в случае сложной графики можно выносить их в поля и обновлять по мере надобности.Дебаг и Logging: Flutter предлагает флажки
debugPaintSizeEnabledи прочие для визуализации границ RenderObject-ов. Если что‑то не сходится в верстке, включайте их (или просто выводите size в консоль), бывает, забыл вызвать layout у дочернего RenderObject, и он не отображается.»
Прежде чем писать свой RenderObject, попробуйте решить задачу средствами фреймворка. Flutter очень гибкий, и много чего можно сделать, не опускаясь на низкий уровень. А если вдруг понадобится что‑то свое, вы знаете, что делать.
Если вам близко ковыряться в UI на уровне рендера, то на iOS можно делать не меньше: на курсе iOS Developer. Professional вы прокачаете Swift, SwiftUI, асинхронность и архитектуры до уровня middle+/senior. Финалом станет продакшн-уровневое приложение в портфолио вместо «игрушечного» pet-проекта с живым разбором кейсов. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
11 декабря. Пишем SPM плагин для автоматизации ревью кода. Записаться
22 декабря. Kotlin Multiplatform и Grpc для iOS. Записаться