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,
),
),
);
}
}
InteractiveVieweris scaled? or just their center points should be pinned to fixed positions on the floor plan without resizing buttons size?InteractiveViewer(grand)children and thy will be scaled automatically