Flutter Interactive SVG

Stepan Bezhuk
6 min readOct 30, 2023

--

Scalable Vector Graphics (SVGs) offer a highly versatile and visually captivating means to present graphics in Flutter applications. This presents a realm of thrilling opportunities for seasoned Flutter developers to elevate user engagement and create truly immersive experiences through the integration of interactive elements into SVGs. The process of seamlessly incorporating Interactive SVG files into our system demands a meticulous adherence to precise standards. Through the parsing of these files, we adeptly extract essential information, including item names, descriptions, and pricing, seamlessly weaving this indispensable data into the fabric of the SVG files to ensure a flawless integration experience with the Flutter application.

As an illustration, I’ll employ an SVG map of Ukraine, you could choose any other SVG that aligns with your specific aims and objectives:

Implementation

We’ve observed the actual result; now, I suggest moving forward to practical implementation. In our implementation, we will use the BLoC architecture to effectively manage the map’s state. Additionally, we will use the xml package and the path_drawing package for efficient parsing of our SVG and rendering it.

Here is the complete list of packages we use in this example:

dependencies:
xml: ^6.3.0
path_drawing: ^1.0.1
flutter_bloc: ^8.1.2
equatable: ^2.0.5

At this point, the next crucial step is to develop our parser for SVG files. Creating a robust SVG file parser is essential to extract and interpret the graphic elements within these files, enabling us to work with them effectively in our project.

Here’s a simple example of a code block:

class Utils {
static Future<List<Region>> loadSvgImage({required String svgImage}) async {
List<Region> maps = [];
String generalString = await rootBundle.loadString(svgImage);

XmlDocument document = XmlDocument.parse(generalString);

final paths = document.findAllElements('path');

for (var element in paths) {
final partId = element.getAttribute('id').toString();
final partPath = element.getAttribute('d').toString();
final zone = element.getAttribute('zone').toString();
final color = element.getAttribute('color')?.toString().replaceAll('#', 'FF') ?? 'D7D3D2';

maps.add(Region(
id: partId,
path: partPath,
color: color,
zone: zone,
));
}

return maps;
}
}

As we can see from the code above, our method loadSvgImage which is static and which is placed in the class Utilstakes one mandatory parameter called svgImage, this will be a reference to our SVG file (we will use a local SVG file, but you can slightly change the logic of this method and use an SVG file from a remote server). In this code, we search for all path nodes, and it is from these nodes that we extract the data we need, such as: id, d, zone, and color.

Also, as we can see, our parser uses the Region data model. Well, let's take a look at our data model so we can see the full picture of our parser.

class Region {
final String id;
final String path;
final String color;
final String zone;

const Region({
required this.id,
required this.path,
required this.zone,
this.color = 'D7D3D2',
});
}

As we can see, this data model has fields such as id, which will contain the unique value of our region, and path, which will contain data for drawing our region. These two elements are the main ones in our model and algorithm. However, what do the additional fields zone and color represent? In these two additional fields, we will store extra information, such as the name of our area on the map and the color of the area on the map.

Now, let’s delve into our BLoC class and explore how we can harness the power of our parser in a seamless and concise manner.

extension BlocExt on BuildContext {
MapPageBloc get mapPageBloc => read<MapPageBloc>();
}

extension MapPageBlocExt on MapPageBloc {
void init() => add(GetSvgEvent());

void onRegionSelected(Region country) => add(RegionSelectedEvent(country));
}

class MapPageBloc extends Bloc<MapPageEvent, MapPageState> {
MapPageBloc() : super(MapPageState.init()) {
on<GetSvgEvent>(_onGetSvgEvent);
on<RegionSelectedEvent>(_onRegionSelectedEvent);
}

FutureOr<void> _onGetSvgEvent(GetSvgEvent event, Emitter<MapPageState> emit) async {
emit(state.copyWith(status: MapPageStatus.loading));

await Utils.loadSvgImage(svgImage: 'assets/map.svg').then((data) {
emit(state.copyWith(regiones: data, status: MapPageStatus.success));
});
}

FutureOr<void> _onRegionSelectedEvent(RegionSelectedEvent event, Emitter<MapPageState> emit) async {
emit(state.copyWith(currentRegion: event.region, status: MapPageStatus.success));
}
}

As we can see, we have a GetSvgEvent event with which we will initiate the parsing process of our SVG file. It is recommended to call this event only once when loading our SVG file to avoid unnecessary operations on our device.

We can also observe another event, RegionSelectedEvent, which is intended for selecting a region on the map that will be sketched. We will invoke this method each time the user clicks on a specific zone. This event can include any business logic, so if needed, you can customize this business logic to suit your task.

As we can see, our BLoC class appears to be quite simple, so I don’t see the need for a more detailed examination of it.

Now, let’s examine the implementation of our UI, which is responsible for displaying our map. For this purpose, we will use StatefulWidget because we need to manage the state of our widget's lifetime flexibly.

class MapPage extends StatefulWidget {
const MapPage({super.key});

@override
State<MapPage> createState() => _MapPageState();
}

class _MapPageState extends State<MapPage> {
@override
void initState() {
super.initState();
context.mapPageBloc.init();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Map Page')),
body: BlocBuilder<MapPageBloc, MapPageState>(
builder: (_, state) {
if (state.status == MapPageStatus.loading) {
return const LoadingWidget();
}

if (state.status == MapPageStatus.error) {
return const InformationMessage(message: 'Oops! Somesing went wrong!');
}

if (state.status == MapPageStatus.success) {
return MapImage(
regiones: state.regiones,
currentRegion: state.currentRegion,
);
}

return SizedBox();
},
),
);
}
}

This widget will serve as the primary component for displaying the map, loading indicator, or handling errors.

We can observe that in the initState method, we invoke our init() method, which is a part of the MapPageBloc class. At this juncture, we will initiate the loading and parsing of our SVG file to obtain all the required data from it. Additionally, this method will only be executed once when the user opens this page.

When the process of downloading the SVG file and parsing it is successfully completed, we will receive a new status for our BLoC class, success, which will indicate that we are ready to draw our interactive SVG. To achieve this, we will render the MapImage widget and provide it with all the necessary data to correctly render our map.

Let’s now look at the MapImage widget.

class MapImage extends StatelessWidget {
final List<Region> regiones;
final Region? currentRegion;

const MapImage({
super.key,
required this.regiones,
this.currentRegion,
});

@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: InteractiveViewer(
maxScale: 5,
minScale: 0.1,
child: Stack(children: _buildClippedImage(context)),
),
),
SafeArea(
child: Text(
currentRegion?.zone ?? '',
style: Theme.of(context).textTheme.headlineLarge,
),
),
],
);
}

List<Widget> _buildClippedImage(BuildContext context) {
final List<Widget> regionesFromSvg = [];

for (var region in regiones) {
double opacity = 1.0;

if (currentRegion != null) {
opacity = currentRegion?.id == region.id ? opacity : 0.3;
}

final color = Color(int.parse(region.color, radix: 16)).withOpacity(opacity);

final regionFromSvg = ClipPath(
clipper: Clipper(svgPath: region.path),
child: GestureDetector(
onTap: () => context.mapPageBloc.onRegionSelected(region),
child: Container(color: color),
),
);

regionesFromSvg.add(regionFromSvg);
}

return regionesFromSvg;
}
}

I would like to draw your primary attention to the _buildClippedImage(BuildContext context) method, where the process of rendering our map, obtained from an SVG file, occurs. As you can see, we utilize the ClipPath widget to render our map. Let’s examine the implementation of our Clipper class to gain a clearer understanding of the entire process of rendering our SVG file.

class Clipper extends CustomClipper<Path> {
const Clipper({required this.svgPath});

final String svgPath;

@override
Path getClip(Size size) {
var path = parseSvgPathData(svgPath);
final Matrix4 matrix4 = Matrix4.identity();

matrix4.scale(0.5);

return path.transform(matrix4.storage).shift(const Offset(0, 0));
}

@override
bool shouldReclip(CustomClipper oldClipper) => false;
}

Now, let’s examine the Clipper class, which will render the regions we obtained earlier by parsing from our SVG file. This class will only accept one parameter called svgPath; in other words, it corresponds to the d=”…” parameter that we obtained from the SVG file. Our region will be drawn based on the data passed through the svgPath parameter.

As we can see, the implementation of this class is relatively straightforward, yet the result is impressive. With this, we can conclude and examine the actual result.

This was my first article on this platform, so I hope you enjoyed reading it. Thanks for your support.

--

--