1

I am building an app that allows the user to build their gyms floorplan and place buttons where the machines would be.

I am currently using an Interactive Viewer to display the floorplan. I need the functionality to create a button widget where the "User" long-presses.

Here is my Current Code with the onLongPressEnd class.

Padding(
              padding: const EdgeInsets.symmetric(vertical: 1),
              child: Center(
                child: GestureDetector(
                  onLongPressEnd: ((details) => {

//Code to create button goes here

                      }),
                  child: InteractiveViewer(
                    minScale: 1,
                    maxScale: 2,
                    child: Stack(
                      children: [
                        Image.asset(
                            'Assets/Lift View Images/room_layout_1.png'),
                          );

Someone on the internet has posted an almost identical problem to this and created a whole solution, the problem is since his solution, the InteractiveViewer widget has been added so all of his code is obsolete and cannot be copied to a newer version.

https://medium.com/@alexander.arendar/dragging-zooming-and-placing-object-on-indoor-map-with-flutter-67667ef415ec

In conclusion I need the functionality for the user to create pre defined widgets by pressing on the page.

Full Code https://github.com/CokeMango/mapview_iteration_1.git

I tried for hours to understand this documents solution, https://medium.com/@alexander.arendar/dragging-zooming-and-placing-object-on-indoor-map-with-flutter-67667ef415ec but never figured out how to implement it with the Interactive Viewer widget. And being fairly new to Flutter I couldn't replicate it exactly.

I also searched online for a while and at most I found what I already had which was a zoomable scrollable image viewer with no functionality.

4
  • and do you want your buttons to be scaled when InteractiveViewer is scaled? or just their center points should be pinned to fixed positions on the floor plan without resizing buttons size? Commented Jan 12, 2023 at 5:00
  • I would prefer the buttons to also be resized, when the InteractiveViewer is manipulated. Commented Jan 13, 2023 at 3:11
  • so simply add them as InteractiveViewer (grand)children and thy will be scaled automatically Commented Jan 13, 2023 at 5:17
  • I need the user to be able to place them. Like a floorplan editor. like this link Commented Jan 14, 2023 at 6:55

1 Answer 1

2

here you can see two approaches: FloorPlanWithFixedButtons adds "buttons" with a fixed size (no matter what the current zoom factor is used by InteractiveViewer), while FloorPlanWithScaledButtons adds "buttons" directly to InteractiveViewer so they are automatically scaled when you zoom-in / zoom-out

if you need draggable "buttons" check FloorPlanWithScaledDraggableButtons widget

class ChipEntry {
  ChipEntry({
    required this.offset,
    required this.label,
  });

  Offset offset;
  final String label;
}

FloorPlanWithFixedButtons

class FloorPlanWithFixedButtons extends StatefulWidget {
  @override
  State<FloorPlanWithFixedButtons> createState() => _FloorPlanWithFixedButtonsState();
}

class _FloorPlanWithFixedButtonsState extends State<FloorPlanWithFixedButtons> with TickerProviderStateMixin {
  int animatedIndex = -1;
  late final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
  final chips = <ChipEntry>[];
  final transformationController = TransformationController();
  int labelNumber = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(
          padding: EdgeInsets.all(8.0),
          child: Text('1) long press on the floor plan below to add a new button\n'),
        ),
        Expanded(
          child: ClipRect(
            child: GestureDetector(
              onLongPressStart: _addButton,
              child: Stack(
                children: [
                  InteractiveViewer(
                    minScale: 1,
                    maxScale: 5,
                    constrained: false,
                    transformationController: transformationController,
                    // https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Sample_Floorplan.jpg/640px-Sample_Floorplan.jpg
                    child: Image.asset('images/640px-Sample_Floorplan.jpg'),
                  ),
                  CustomMultiChildLayout(
                    delegate: FloorPlanDelegate(
                      chips: chips,
                      transformationController: transformationController,
                    ),
                    children: [
                      for (int index = 0; index < chips.length; index++)
                        LayoutId(id: index, child: _button(index)),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget _button(int index) {
    final button = Chip(
      backgroundColor: Colors.orange,
      side: const BorderSide(width: 1, color: Colors.black12),
      elevation: 4,
      onDeleted: () async {
        setState(() {
          animatedIndex = index;
        });
        await controller.reverse(from: 1.0);
        setState(() {
          chips.removeAt(index);
          animatedIndex = -1;
        });
      },
      label: InkWell(
        onTap: () => print('button |${chips[index].label}| at index $index pressed'),
        child: Text(chips[index].label),
      ),
    );
    return index == animatedIndex? ScaleTransition(scale: controller, child: button) : button;
  }

  void _addButton(LongPressStartDetails details) async {
    setState(() {
      animatedIndex = chips.length;
      final chipEntry = ChipEntry(
        offset: transformationController.toScene(details.localPosition),
        label: 'btn #$labelNumber'
      );
      chips.add(chipEntry);
      labelNumber++;
    });
    await controller.forward(from: 0.0);
    animatedIndex = -1;
  }
}

class FloorPlanDelegate extends MultiChildLayoutDelegate {
  FloorPlanDelegate({
    required this.chips,
    required this.transformationController,
  }) : super(relayout: transformationController); // NOTE: this is very important

  final List<ChipEntry> chips;
  final TransformationController transformationController;

  @override
  void performLayout(ui.Size size) {
    // print('performLayout $size');
    int id = 0;
    final constraints = BoxConstraints.loose(size);
    final matrix = transformationController.value;
    for (final chip in chips) {
      final size = layoutChild(id, constraints);
      final offset = MatrixUtils.transformPoint(matrix, chip.offset) - size.center(Offset.zero);
      positionChild(id, offset);
      id++;
    }
  }

  @override
  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => false;
}

FloorPlanWithScaledButtons

class FloorPlanWithScaledButtons extends StatefulWidget {
  @override
  State<FloorPlanWithScaledButtons> createState() => _FloorPlanWithScaledButtonsState();
}

class _FloorPlanWithScaledButtonsState extends State<FloorPlanWithScaledButtons> with TickerProviderStateMixin {
  int animatedIndex = -1;
  late final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
  final chips = <ChipEntry>[];
  final transformationController = TransformationController();
  int labelNumber = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(
          padding: EdgeInsets.all(8.0),
          child: Text('1) long press on the floor plan below to add a new button\n'),
        ),
        Expanded(
          child: ClipRect(
            child: GestureDetector(
              onLongPressStart: _addButton,
              child: InteractiveViewer(
                minScale: 1,
                maxScale: 5,
                constrained: false,
                transformationController: transformationController,
                child: Stack(
                  children: [
                    // https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Sample_Floorplan.jpg/640px-Sample_Floorplan.jpg
                    Image.asset('images/640px-Sample_Floorplan.jpg'),
                    ...chips.mapIndexed(_positionedButton),
                  ],
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget _positionedButton(int index, ChipEntry chip) {
    final child = Chip(
      backgroundColor: Colors.orange,
      side: const BorderSide(width: 1, color: Colors.black12),
      elevation: 4,
      onDeleted: () async {
        setState(() {
          animatedIndex = index;
        });
        await controller.reverse(from: 1.0);
        setState(() {
          chips.removeAt(index);
          animatedIndex = -1;
        });
      },
      label: InkWell(
        onTap: () => print('button |${chip.label}| at index $index pressed'),
        child: Text(chip.label),
      ),
    );

    return Positioned(
      left: chip.offset.dx,
      top: chip.offset.dy,
      child: FractionalTranslation(
        translation: const Offset(-0.5, -0.5),
        child: index == animatedIndex? ScaleTransition(scale: controller, child: child) : child,
      ),
    );
  }

  void _addButton(LongPressStartDetails details) async {
    setState(() {
      animatedIndex = chips.length;
      final chipEntry = ChipEntry(
        offset: transformationController.toScene(details.localPosition),
        label: 'btn #$labelNumber'
      );
      chips.add(chipEntry);
      labelNumber++;
    });
    await controller.forward(from: 0.0);
    animatedIndex = -1;
  }
}

FloorPlanWithScaledDraggableButtons

class FloorPlanWithScaledDraggableButtons extends StatefulWidget {
  @override
  State<FloorPlanWithScaledDraggableButtons> createState() => _FloorPlanWithScaledDraggableButtonsState();
}

class _FloorPlanWithScaledDraggableButtonsState extends State<FloorPlanWithScaledDraggableButtons> {
  final chips = <ChipEntry>[];
  final transformationController = TransformationController();
  int labelNumber = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Padding(
          padding: EdgeInsets.all(8.0),
          child: Text('1) long press on the floor plan below to add a new button\n'
          '2) long press on the added button to drag it'),
        ),
        Expanded(
          child: ClipRect(
            child: GestureDetector(
              onLongPressStart: _addButton,
              child: InteractiveViewer(
                minScale: 1,
                maxScale: 5,
                constrained: false,
                transformationController: transformationController,
                child: Stack(
                  children: [
                    // https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Sample_Floorplan.jpg/640px-Sample_Floorplan.jpg
                    Image.asset('images/640px-Sample_Floorplan.jpg'),
                    ...chips.mapIndexed(_button),
                  ],
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget _button(int index, ChipEntry chip) {
    return DraggableChip(
      chip: chip,
      onTap: () => print('button |${chip.label}| at index $index pressed'),
      onDrag: (delta) => setState(() => chip.offset += _scaled(delta)),
      onDeleted: () => setState(() => chips.removeAt(index)),
    );
  }

  Offset _scaled(Offset delta) {
    return delta / transformationController.value.getMaxScaleOnAxis();
  }

  void _addButton(LongPressStartDetails details) {
    setState(() {
      final chipEntry = ChipEntry(
        offset: transformationController.toScene(details.localPosition),
        label: 'btn #$labelNumber'
      );
      chips.add(chipEntry);
      labelNumber++;
    });
  }
}

class DraggableChip extends StatefulWidget {
  const DraggableChip({
    Key? key,
    required this.chip,
    this.onTap,
    this.onDrag,
    this.onDeleted,
  }) : super(key: key);

  final ChipEntry chip;
  final VoidCallback? onTap;
  final Function(Offset)? onDrag;
  final VoidCallback? onDeleted;

  @override
  State<DraggableChip> createState() => _DraggableChipState();
}

class _DraggableChipState extends State<DraggableChip> with SingleTickerProviderStateMixin {
  late final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
  bool drag = false;
  Offset position = Offset.zero;
  double scale = 0;

  @override
  void initState() {
    super.initState();
    controller.forward();
  }

  @override
  void didUpdateWidget(covariant DraggableChip oldWidget) {
    super.didUpdateWidget(oldWidget);
    scale = controller.value = 1;
  }

  @override
  Widget build(BuildContext context) {
    final child = RawChip(
      selected: drag,
      showCheckmark: false,
      selectedColor: Colors.teal,
      backgroundColor: Colors.orange,
      side: const BorderSide(width: 1, color: Colors.black12),
      elevation: 4,
      onDeleted: () async {
        await controller.reverse();
        widget.onDeleted?.call();
      },
      label: GestureDetector(
        onLongPressStart: (d) => setState(() {
          drag = true;
          position = d.globalPosition;
        }),
        onLongPressMoveUpdate: (d) {
          widget.onDrag?.call(d.globalPosition - position);
          position = d.globalPosition;
        },
        onLongPressEnd: (d) => setState(() => drag = false),
        child: InkWell(
          onTap: widget.onTap,
          child: Text(widget.chip.label),
        ),
      ),
    );
    return Positioned(
      left: widget.chip.offset.dx,
      top: widget.chip.offset.dy,
      child: FractionalTranslation(
        translation: const Offset(-0.5, -0.5),
        child: ScaleTransition(
        scale: controller,
          child: child,
        ),
      ),
    );
  }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Thank You, this looks to be a perfect solution to my problem. However the only problem I am having is with the 'ChipEntry' class. My IDE (VSCODE) is calling it as a bug and returns this, "The name 'ChipEntry' isn't a type so it can't be used as a type argument. Try the name to an existing type, or defining a type named 'ChipEntry'"
This is only a problem for the Scalable versions, however in the Fixed Buttons version there is an error with the 'ui.Size' class, saying that it is undefined.
@CokeMango ChipEntry class is defined in the first code snippet, just copy it, for ui.Size you need import 'dart:ui' as ui;

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.