I’ve decided to implement a PoC for one feature which we probably will need in our product. Suppose that a user wants to upload to the application a drawing/plan of a building floor or some expo center and being able to navigate, zoom and place some objects on that indoor map. Also user would like those objects on the map to be clickable so the app can show some additional info/actions/etc. in case the user taps on the object.

One more thing which motivated me to spent time on this PoC was this question on Stackoverflow which had no answers at the time when I’ve encountered it.

The UI of the app looks like following:

I’ve tried to google if there is already something available for this in Flutter but didn’t find anything (probably my poor googling skills), so I’ve started to think and try what I can use to code it myself. I must admit that I am quite new to Flutter/Dart so the approach I describe below probably is not optimal.

Full code is available in GitHub repo.

First I’ve tried to use OverflowBox, SizedOverflowBox, ClipRect, Image and their different combinations but it was not ideal in different aspects. In the end I’ve stopped on using Canvas, CustomPainter, drawImageRect and Stack with Positioned. Now let’s dig in.

My wish was to be able to use arbitrary Widget-s as objects I can place onto the map. So the abstraction to represent objects on the map is a following simple class:

It is convenient to use standard Offset class for object position on the map/drawing. Offset(0, 0) represents center of the map, while e.g. Offset(-1, -1) represents top left corner of the map.

Also standard Size class is used to represent size of the object for zoomLevel==1. I want objects on the map to update their size when user zooms in/out.

The main widget which actually performs all calculation for positioning and rendering is called ImageViewport. It is a stateful widget and you can feel it’s design and purpose from the code below:

So this widget accepts a zoom level, a map/drawing source as ImageProvider (it is convenient since it allows later to use an image from assets, file system or network) and a list of MapObject-s as parameters. The latter is needed if you have stored in your state set of objects on the map and want to load it and render later.

This ImageViewport is wrapped into another widget, ZoomContainer which places two icons atop of everything else and allows user to increase/decrease zoomLevel and rebuilds the underlying ImageProvider when zoomLevel changes. The whole code you can find in GitHub and below is just a build() method of the ZoomContainer so you can get the idea:

Corresponding ImageViewportState is quite heavy on the code and you can view it in GitHub. Here we will go through main conceptual pieces. First of all I use LayoutBuilder at the top of widget hierarchy to be able to fit the map properly in the available space and react to events like device orientation change:

Important thing to notice is that below the LayoutBuilder there is a GestureDetector to handle drag/pan events and to allow user to place new map object on the map by long press. It is worth to mention that when the map fits on the screen in one dimension, e.g. if it is long horizontally and zoom level is low, then only handling pan event will result in poor drag experience. So the app is smart and handles such cases by dynamically adding/removing onPanUpdate, onHorizontalDragUpdate and onVerticalDragUpdate handlers.

Inside the GestureDetector we have a Stack. At the bottom of the Stack we have CustomPaint with a custom painter which actually renders corresponding to current zoomLevel, centerOffset, devicePixelRatio and underlying map/drawing bitmap image.widht/height piece of the original bitmap in our ImageProvider. Atop of this in our Stack goes a set of Positioned widgets each of which renders one MapObject from the buildObjects() invocation result.

For simplicity I’ve decided to have new map objects as a colored Container widgets with particular size. But it may be any Widget as you can see from the code.

This buildObjects() function maps our collection List<MapObject> of object with relative offsets as coordinates to a list of Positioned widgets with actual absolute coordinates + it also wraps each MapObject’s child widget in a Container with proper size. Take a note that it wraps each in GestureDetector as well so the user can tap on the map object and see some reaction, in my case just a simple widget which renders some alert text in the proper position and allows user to close it:

Now our CustomPainter which renders needed part of the underlying bitmap image to our viewport. Its main workhorse is Canvas.drawImageRect() method:

Interesting things are also calculations how to convert relative offsets for map objects to local coordinates and vice versa, how to track offset of the map center when user drags the map, how to update this center offset when the zoom level changes ,etc. Involved methods in the ImageViewportState are _globaltoLocalOffset(), _localToGlobalOffset(), handleDrag(). There’s nothing very complex there, but it’s too beefy to include here so you can look in the GitHub repo and play with it yourself.

In the end I’ve come up with a proof of concept widget which allows me to embed it easily in my app, pass it default zoom level and a collection of map objects as you can see below:

One more interesting thing to pay attention to is how to initialize the bitmap data from the ImageProvider. It is in the didChangeDependencies() method of the _ImageViewportState class:

This exercise proved me that it is possible to implement such features as “Indoor Maps” in Flutter. Of course in order to evolve it into some real production component a lot of things need to be done but it is clear what to use.

Hope this reading was useful for you. If you have any notes/suggestions/questions please comment.