Как я реализовал планировщик дома для системы умного дома
Поступила мне, значит, задача — «Написать функционал для создания плана квартиры/дома». Что от меня ожидалось:
- Интерактивная карта
- Создание комнат (стен)
- Размещение устройств на карте.
И подзадачи, которые появились по ходу:
- Размещение по сетке
- Склеивание полигонов, находящихся близко друг к другу (без сетки).
Теперь, когда мы выяснили задачи, перейдём к разбору каждой.
Интерактивная карта
Тут всё легко. Во Flutter есть InteractiveViewer
, который даёт возможность сделать интерактивное пространство, в котором можно перемещаться и масштабировать.
InteractiveViewer(
constrained: false, // Указываем false, чтобы пространство не ограничивалось размерами экрана устройства
child: Stack(
children: [
// Делаем контейнер, который будет являться "background" нашего пространства. Именно на его размерах будет определяться размер всего пространства.
Container(width: 5000, height: 5000)
]
),
)
Теперь мы имеем базу, в которой будем строить нашу планировку.
Создание стен
Вот тут начинается самое интересное. Это не просто перемещение объектов по полю.
Первое, что пришло в голову — использовать Painter
и с его помощью рисовать стены. Придумал — сделал. Получилось то, что нужно, но не полностью.
class WallObjectPainter extends CustomPainter {
const WallObjectPainter({required this.points});
final List<Offset> points;
@override
void paint(Canvas canvas, Size size) {
// Создаём "кисть"
final paint = Paint()
..color = Colors.black
..strokeWidth = 5;
// Рисуем стену
canvas.drawPoints(PointMode.polygon, points, paint);
}
}
Теперь у нас есть класс, который рисует стену. points
— массив точек для стены, то есть координаты, где она начинается и заканчивается.
Тут возникает мысль: «У нас же могут быть разные объекты, надо сделать для них одну базу». Так появляется класс LayoutObject
, который становится основой для размещаемых в пространстве объектов.
sealed class LayoutObject {
Widget build(BuildContext context);
abstract final List<Offset> polygons;
}
И на его базе сразу делаем класс стены:
final class WallLayoutObject extends LayoutObject {
WallLayoutObject(this.polygons) : assert(polygons.isNotEmpty, 'Polygons array cannot be empty');
@override
final List<Offset> polygons;
@override
Widget build(BuildContext context) {
//Отрисовываем нашу стену
return CustomPaint(painter: WallObjectPainter(points: polygons));
}
}
Что теперь имеем? У нас есть базовый класс LayoutObject
для размещаемых объектов, WallObjectPainter
— рисовальщик, и WallLayoutObject
— представление стены.
Окей, а кто будет управлять этими объектами? Ответ — создаём менеджера.
Размещение устройств на карте
Нам нужен кто-то, кто будет управлять процессом:
class HouseLayoutObjectsManager {
HouseLayoutObjectsManager(this.layoutObjects);
// Обхекты, которые размещены в пространстве
final List<LayoutObject> layoutObjects;
// Может ли пользователь взаимодействовать с пространством, а именно перемещать и скейлить
bool _isPanAndScaleEnabled = true;
bool get isPanAndScaleEnabled => _isPanAndScaleEnabled;
// Объект, с которым взаимодействует пользователь
SelectedLayoutObjectIndexes? _selectedLayoutObjectIndexes;
// Дистанция для склеивания
final double _snapDistance = 15.0;
//Д истанция для определения, что пользователь трогает определённый полигон
final double _selectPolygonDistance = 20.0;
// Размер сетки
final double matrixLayoutSpacing = 25.0;
// Включена ли сетка
bool matrixSnapEnabled = false;
// Включен ли режим удаления
bool removeModeEnabled = false;
// Служебная функция для конвертирования координат
Offset _convertGlobalPositionToLocal(Offset focalPoint) {
final matrix = controller.value;
final inverseMatrix = Matrix4.inverted(matrix);
final localPosition = MatrixUtils.transformPoint(inverseMatrix, focalPoint);
return localPosition;
}
Offset? _checkPolygonsForSnap(Offset localPos);
bool interactionStart(Offset localFocalPoint);
bool interactionUpdate(Offset localFocalPoint);
void interactionEnd();
void addLayoutObject(LayoutObject object) => layoutObjects.add(object);
void dispose() {
controller.dispose();
}
}
Разберём некоторые методы подробнее.
bool interactionStart(Offset localFocalPoint) {
final localPos = _convertGlobalPositionToLocal(localFocalPoint);
if (_selectedLayoutObjectIndexes != null) return false;
final foundObjects = <SelectedLayoutObjectIndexes>[];
for (int i = 0; i < layoutObjects.length; i++) {
final layoutObject = layoutObjects[i];
for (var j = 0; j < layoutObject.polygons.length; j++) {
final objectPolygon = layoutObject.polygons[j];
final distance = localPos - objectPolygon;
if (distance.distance <= _selectPolygonDistance) {
if (removeModeEnabled) {
layoutObjects.removeAt(i);
return true;
}
foundObjects.add(SelectedLayoutObjectIndexes(i, j));
}
}
}
if (foundObjects.isNotEmpty) {
_selectedLayoutObjectIndexes = foundObjects.last;
_isPanAndScaleEnabled = false;
}
return true;
}
В этом методе мы ищем полигон, который пользователь "трогает". Для этого проходим по каждому объекту и его полигонам, проверяя расстояние до пальца. Если объект найден — добавляем его в список. Зачем собирать все рядом расположенные объекты? Потому что в одной точке может быть несколько объектов, и мы хотим перетаскивать самый верхний. Последний в списке и будет самым верхним.
Также стоит обратить внимание, что мы выключаем _isPanAndScaleEnabled
, чтобы пользователь не мог одновременно перемещать и объект, и пространство — это вызовет баги.
Разберём ещё один метод:
bool interactionUpdate(Offset localFocalPoint) {
if (_selectedLayoutObjectIndexes == null) return false;
final localPos = _convertGlobalPositionToLocal(localFocalPoint);
// // Поиск ближайшей точки
final newPos = _checkPolygonsForSnap(localPos) ?? localPos;
//todo избавиться от !
layoutObjects[_selectedLayoutObjectIndexes!.objectIndex].polygons[_selectedLayoutObjectIndexes!.polygonIndex] = newPos;
return true;
}
Здесь мы перемещаем полигон. Почему конвертируем координаты? Потому что onInteractionUpdate
даёт координаты относительно экрана, а не пространства InteractiveView
. После этого — проверяем, не нужно ли "приклеить" точку к ближайшей, и сохраняем новое положение.
Размещение устройств на карте
Тут от нас ничего не требуется - достаточно создания нового класса-ребёнка LayoutObject
, и всё.
Сетка
Сетку я решил сделать с помощью того же Painter
:
class LayoutGridPainter extends CustomPainter {
const LayoutGridPainter(this.layoutSize, this.spacing);
final Size layoutSize;
final double spacing;
@override
void paint(Canvas canvas, Size size) {
final verticalCount = layoutSize.width ~/ spacing;
final horizontCount = layoutSize.height ~/ spacing;
final paint = Paint()
..color = const Color.fromARGB(255, 206, 200, 200)
..strokeWidth = 1;
for (var i = 0; i < horizontCount; i++) {
final dy = i * spacing;
final o1 = Offset(0, dy);
final o2 = Offset(layoutSize.width, dy);
canvas.drawLine(o1, o2, paint);
}
for (var i = 0; i < verticalCount; i++) {
final dx = i * spacing;
final o1 = Offset(dx, 0);
final o2 = Offset(dx, layoutSize.height);
canvas.drawLine(o1, o2, paint);
}
}
}
Финальный вид виджета
InteractiveViewer(
key: layoutObjectKey,
transformationController: _manager.controller,
onInteractionStart: (details) {
final result = _manager.interactionStart(details.localFocalPoint);
if (result) {
setState(() {});
}
},
onInteractionUpdate: (details) {
final result = _manager.interactionUpdate(details.localFocalPoint);
if (result) {
setState(() {});
}
},
onInteractionEnd: (details) {
setState(() {
_manager.interactionEnd();
});
},
constrained: false,
scaleEnabled: _manager.isPanAndScaleEnabled,
panEnabled: _manager.isPanAndScaleEnabled,
child: Stack(
children: [
Container(width: backgroundInteractiveViewSize.width, height: backgroundInteractiveViewSize.height, color: context.colors.background.defaultColor),
if (_manager.matrixSnapEnabled) HouseLayoutGrid(size: backgroundInteractiveViewSize, spacing: _manager.matrixLayoutSpacing),
for (final points in _manager.layoutObjects) points.build(context),
],
),
),
Что получили в итоге
У нас есть:
- База для объектов
- Менеджер
- Виджет с пространством.
Теперь можно просто добавлять новые классы объектов — и они готовы к размещению. Больше ничего не требуется.
Спасибо за прочтение. Много моментов я опустил, например, создание и удаление объектов — уже почти 10k символов, а с ними было бы ещё больше.
Получилась такая вот выжимка из большой задачи. Постарался выделить ключевые моменты.
Буду рад вашему мнению в комментариях — и, конечно, найдутся те, кто придерутся к коду