Skip to content

Как я реализовал планировщик дома для системы умного дома

Поступила мне, значит, задача — «Написать функционал для создания плана квартиры/дома». Что от меня ожидалось:

  • Интерактивная карта
  • Создание комнат (стен)
  • Размещение устройств на карте.

И подзадачи, которые появились по ходу:

  • Размещение по сетке
  • Склеивание полигонов, находящихся близко друг к другу (без сетки).

Теперь, когда мы выяснили задачи, перейдём к разбору каждой.

Интерактивная карта

Тут всё легко. Во Flutter есть InteractiveViewer, который даёт возможность сделать интерактивное пространство, в котором можно перемещаться и масштабировать.

dart
InteractiveViewer(
    constrained: false, // Указываем false, чтобы пространство не ограничивалось размерами экрана устройства
    child: Stack(
        children: [
            // Делаем контейнер, который будет являться "background" нашего пространства. Именно на его размерах будет определяться размер всего пространства.
            Container(width: 5000, height: 5000)
        ]
    ),
)

Теперь мы имеем базу, в которой будем строить нашу планировку.

Создание стен

Вот тут начинается самое интересное. Это не просто перемещение объектов по полю.

Первое, что пришло в голову — использовать Painter и с его помощью рисовать стены. Придумал — сделал. Получилось то, что нужно, но не полностью.

dart
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, который становится основой для размещаемых в пространстве объектов.

dart
sealed class LayoutObject {
  Widget build(BuildContext context);

  abstract final List<Offset> polygons;
}

И на его базе сразу делаем класс стены:

dart
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 — представление стены.

Окей, а кто будет управлять этими объектами? Ответ — создаём менеджера.

Размещение устройств на карте

Нам нужен кто-то, кто будет управлять процессом:

dart

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();
  }
}

Разберём некоторые методы подробнее.

dart
  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, чтобы пользователь не мог одновременно перемещать и объект, и пространство — это вызовет баги.

Разберём ещё один метод:

dart
  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:

dart
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);
    }
  }
}

Финальный вид виджета

dart
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 символов, а с ними было бы ещё больше.

Получилась такая вот выжимка из большой задачи. Постарался выделить ключевые моменты.

Буду рад вашему мнению в комментариях — и, конечно, найдутся те, кто придерутся к коду