Building macOS Inspired Doc In Flutter
A Flutter desktop experience
Originally published on Pieces blog — https://code.pieces.app/blog/build-a-macos-inspired-dock-with-flutter
A couple of months back, I started doing some UI challenges✨ in Flutter. The goal of these challenges was to be creative and explore building unique UI experiences in Flutter while sticking to what the Framework offers.
As a result, I did happen to create some 😁 really interesting animations, and designs in Flutter which showcase what you can build with it. In this article, I’ll walk you through one of my creation inspired by the macOS Dock experience.
By the end of this article, along with how to build this Doc, you’ll have learned a little more about
- Constraints in Flutter
- Implicit Animations
- Design breakdown
Getting Started :
Download the starter project from here.
Once you build it, you’ll see a black screen but we’ll soon add our beautiful Doc there.🧑🏽💻
Build & run :
Constraints And Implicit Animations :
Before getting our hands dirty in the code, I would like to ask you if you’re familiar with Constraints and Implicit Animations in Flutter?🤔
If you’re, then you can skip the next two sections which talk about Constraints and Implicit Animations in short, and 🏎 race straight up to the Design Breakdown section.
But if you’re not familiar, then you should read through the following two sections to understand some basics of these two topics.
Understanding Constraints :
UI development in Flutter is quite different from the way it’s in Html and other frameworks.
We don’t deal with sizes directly but deal with constraints. The constraints are basically a set of four points, the min-max width, and the min-max height.
There’s this one rule of thumb that guides the UI development in Flutter.
While you’re building UI in Flutter, you will see this parent-child composition. When the parent widget wants to lay out on the screen it checks if it has any children. If there are, then it passes its constraints to its children and asks them how big/small they want to be. The child widget may repeat this process if it has children of its own.
Once the Parent gets the sizes for each of its child widget, it then checks those sizes against its own constraints, and based on this it calculates the final size for each of its child widget and lays them out.
As you can see, the one who’s in control of determining the size and position of a widget is the Parent, Not the Child widget.
That was constraints in short! This is a way bigger topic to continue in this article. If you want to read more about this in-depth then check out the official docs.
Now that you have a basic idea of constraints, let’s march forward!
Implicit Animations:
One of the reasons building beautiful animations in Flutter is easy is that it provides many ways to animate our widgets depending on our use case.
One of the ways is using Implicit Animation Widgets or also called as AnimatedFoo widgets where Foo is the property that you want animate.
When working with AnimatedFoo widgets, you only have to worry about providing the property value. AnimatedFoo widgets handles the animation flow, or the transition from the old value to the new value whenever the property value changes.
There are different AnimatedFoo widgets for animating different properties on a widget like AnimatedScale, AnimatedOpacity, AnimatedSize, AnimatedContainer, etc.
We’ll be making use of some of these AnimatedFoo widgets. You can learn more about them through the official docs.
Design Breakdown:
When you’re trying to build animations or any kind of interaction then try always to break down the design in parts. And then understand each part and its animations and how it’s affected by other components in the design.
Below is a breakdown of our design
As you can see there are two main components here :
- The row of items
- The Dock
The item’s row is stacked on top of the Doc. Both of these components have different behaviour when you hover over any one of the items. Our items scale and translate along Y-axis while the Docs width increases as items are scaled.
In the next sections, we’ll explore the working of these animations in detail and see how we can implement them.
Adding The Items :
In the lib/macos_doc.dart, replace the //TODO: add items ui
with the following code:
Let’s go over the code step-by-step :
- Items Row: The Row holding the items has the
mainAxisSize
set tomainAxisSize.min
. This tells the row to size itself to the combined width of the items within it, instead of expanding to capture all the available width along the main axis.
2. MouseRegion: Flutter provides a MouseRegion widget which we can use to get notified when the user hovers on our item. When the mouse is within the item hover region we set the hoveredIndex
to that item’s index and reset it to null
when it exits the item’s hover region.
3. AnimatedContainer: This will be the container that will hold our item. It’s an AnimatedContainer which will animate its scale and translate itself depending on the hoveredIndex
.
4. FittedBox: If the child of this widget has a size larger than the constraints passed down to it, then this widget will scale and position the child to fit within the constraints based on the fit set.
5. The item: The item in our case is a text, so I’m using the AnimatedDefaultTextStyle for scaling it. Your item can be anything else like an icon, SVG, etc. Use the appropriate animation widgets to get the smooth scale animation for them.
Build & run :
Animating The Items :
Let’s dive a little deeper into our items animation.
The item has two animation properties, the first one is the change in its size and the second is its translation along Y-axis. The item in focus has the max scale and the max translation along Y-axis. The other items are then scaled and translated accordingly.
Now both of these properties change for items depending on their position from the hovered item. The trick to this animation is to calculate these two properties based on the items index and the hoveredIndex
.
Let’s see how we can calculate those values!
Replace the //TODO: add getPropertyValue method
with the following code :
First of all, if you’re scared a bit😅 coze of all the math this method is screaming📢 at you, don’t worry! It’s actually really simple😄 and I’ll try my best to explain what’s going on here.
The getPropertyValue
method is a general property value calculator for our scale and translation properties. It has four parameters.
- index: index of the item for whom the value needs to be calculated.
- baseValue: Value when none of the items are in focus.
- maxValue: Value for the hovered item.
- nonHoveredMaxValue: Maximum value for items around the hovered item.
Going over the method step-by-step :
- You check if
hoveredIndex
is null, if it’s then returnbaseValue
. - Here you calculate the difference between the
index
of the hovered item and the current. Note that we take the positive of the difference. So, if we try to plot thedifference
for our items by assuming thehoveredIndex
is 2 then for eachindex
we get the following differences as shown by the graph.
[0] -> 2, [1] ->1, [2] ->0, [3]->1,[4]->2
Note how the difference is distributed equally on left and right of the hoveredIndex
.
4. For the hovered item, the difference would be 0 as hoveredIndex
and index would be the same. And so we return the maxValue
for it.
5. If the difference is less than or equal to the itemsAffected , then we do two things, first calculate a ratio
, which is a value between 0.0–1.0 by subtracting the difference
from itemsAffected
and then dividing it by itemsAffected
.
The propertyValue
is then calculated by doing a lerpDouble
over the baseValue
and nonHoveredMaxValue
, the ratio decides the lerpDouble
percentage.
To understand what lerpDouble
is actually doing, take a look at the following explainer,
a represents baseValue
and b represents nonHoveredMaxValue
.
For a ratio of 0.0, the lerp percentage would be 0%, so the propertyValue
will be baseValue
, for a ratio of 1.0 the lerp percentage would be 100%, i.e the nonHoveredMaxValue
.
For any other value of the ratio, the propertyValue
will lie within baseValue
and nonHoveredMaxValue
. Thus, items that are closer to the hoveredIndex will have slightly greater value than those that are far.
6. If any of the conditions don’t match, we simply return the baseValue
.
Now, to finally calculate our scale value and translation values for items, replace the //TODO: add scale and translation calculator methods
with the following code:
In your items UI code, replace the baseItemHeight
with the getScaledSize(index)
and baseTranslationY
with the getTranslationY(index)
.
Test your animations by hovering over the items!🙌
Build & run :
Adding Doc:
The most interesting part of this design is how we size the Doc.If you look at the animation carefully, then you’ll realize that the width of the Doc is always equal to the width of the item’s row and its height is equal to the baseHeight of the item’s row when none of the items are in focus. As the item’s scale, so does the width of the doc.
But, when the items are animating we don’t know what the width of the item’s row will be at any point in the interaction. So, how can we go around this?
We’ll use our knowledge of constraints to solve this. We need to position our Doc in the Stack such that the constraints passed down to it will force it to have the maximum width of the Stack but limit the height to what we want.
Let’s see how we can do this!
Replace the //TODO: add Doc
with the following code:
Welcome, Positioned widget! This widget is used to position widgets within Stack. Here we tell the Positioned widget that the child should be positioned to 0 from the left and right edges of the Stack and will have a height equal to baseItemHeight
. These constraints are then passed down to the DecoratedBox.
One thing to understand about DecoratedBox is that it will size itself to its child’s size when the child is present and the constraints passed to it are not forced constraints.
Here, as the child is not present, the DecoratedBox should have zero size but our Positioned widget forces it to scale within the constraints we defined as the DecoratedBox itself doesn’t provide any information about its size and positioning.
Hot reload the app one final time to see our complete interaction in action!💯
Build & run :
You did it!🥳 You now have a really cool Doc for your Flutter apps!💙
Src Code : MacOS Inspired Doc In Flutter
Next:
🙏 Thanx for spending your precious time reading the article. Hope you had a blast🙌 reading and learned some cool things!🙇♂️
If you want to check out my other creations then head over to this repo .
Have an AMAZING day and keep Fluttering!💙