Creating Advanced Shimmer Loading Effect in Flutter

Nikhithsunil
8 min readFeb 18, 2024

Do you have a drought that Showing an advanced loading effect on a mobile application is necessary? most developers are using the default CircularprogressIndicator, This can solve our problem so why do we waste our presius time creating a complicated Loading?

Wait.. wait.. wait, I can explain 😇.

As all we know First impression is the Best impression to impress a Girl Oh!..sorry, to impress our users😌.

Loading screens are often the first interaction users have with an app. A smooth and visually appealing loading process creates a positive first impression, setting the tone for the overall user experience. And also Well-designed loading animations and indicators can positively influence the perceived performance of our application. Users are more likely to perceive an app as responsive and reliable when encountering thoughtful loading elements.

So if you are excited to create a seamless Loading effect on your application, follow this article. This article includes a clear set of workflows and code samples to create an advanced shimmer loading effect, as seen in many applications like YouTube, Paytm, etc...

Creating Shimmer Shapes

The shapes that shimmer in the effect are independent from the actual content. There the goal is to create an accurate shape that matches the outline of the content. For example, the illustration above has a rounded rectangle-shaped search box, a button, some circular images, and some text. so we can create a shimmer for the components except for the text by just focusing on the outline of the components.

In the case of Text on the UI, it is not possible to create a shimmer for the entire lines of text, therefor we just draw a couple of very thin rounded rectangles that represent the text that will appear.

You can create some different shapes as follows,

class RoundedRectangle extends StatelessWidget {
final double height;
final double width;
const RoundedRectangle({
super.key,
this.height = 50,
this.width = double.maxFinite,
});

@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(15),
));
}
}

These shapes are later used to paint the shimmer in case of whether the UI boundary is undefined while loading. we can discuss this in more detail later in the article.

class FacultyTile extends StatefulWidget {
final Faculty faculty;
final bool isLoading;
const FacultyTile({
Key? key,
required this.faculty,
required this.isLoading,
}) : super(key: key);

@override
State<FacultyTile> createState() => FacultyTileState();
}

class FacultyTileState extends State<FacultyTile> {
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: EdgeInsets.zero,
horizontalTitleGap: 10,
leading: userProfile(profile: widget.faculty.facultyImage, radius: 25),
title: _buildFacultyTile());
}

Widget _buildFacultyTile() {
if (widget.isLoading) {
return const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RoundedRectangle(height: 15),
SizedBox(height: 5),
RoundedRectangle(height: 15, width: 100)
],
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${widget.faculty.staffId ?? ''}-${widget.faculty.staffName ?? ''}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 5),
Text(widget.faculty.depName ?? '',
style: const TextStyle(fontSize: 10, color: primeInterfaceColor))
]);
}

Widget userProfile({required String? profile, double radius = 20, Key? key}) {
return CircleAvatar(
key: key,
radius: radius,
backgroundColor: Colors.grey[200],
backgroundImage: const AssetImage('images/Staff-Placeholder.jpg'),
foregroundImage: profile != null ? NetworkImage(profile) : null,
);
}
}



class RoundedRectangle extends StatelessWidget {
final double height;
final double width;
const RoundedRectangle({
super.key,
this.height = 50,
this.width = double.maxFinite,
});

@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(15),
));
}
}

Keep in mind: always try to give a clear boundary to the components, There are some cases where we can't define a perfect boundary in such cases please use custom shapes that match the outline of UI as we do for text to show on loading time.

Your UI now renders itself differently depending on whether it’s loading or loaded. Now we need to paint all the colored areas with a similar gradient which looks like a shimmer.

Paint Shimmer Shapes

Till now we have created a Design system that will always return a Widget with a clear boundary while on loading.

Our next goal is to paint a shimmer within this boundary while loading, To achieve this we use a widget called ShadeMask.

ShaderMask is a widget that applies a mask generated by a Shader to its child, but only in the areas where the child already painted something. For example, we will apply a shader to only the black shapes that we configured earlier.

first things first, just create a linear gradient

static const _shimmerGradient = LinearGradient(
colors: [
Color(0xFFEBEBF4),
Color(0xFFF4F4F4),
Color(0xFFEBEBF4),
],
stops: [
0.1,
0.3,
0.4,
],
begin: Alignment(-1.0, -0.3),
end: Alignment(1.0, 0.3),
tileMode: TileMode.clamp,
);

Create a Stateful widget named ShimmerLoading to wrap a widget with ShaderMask and apply the gradient as a shader.

class ShimmerLoading extends StatefulWidget {
const ShimmerLoading({
super.key,
required this.isLoading,
required this.child,
});

final bool isLoading;
final Widget child;

@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}

class _ShimmerLoadingState extends State<ShimmerLoading> {
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}

return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
return _shimmerGradient.createShader(bounds);
},
child: widget.child,
);
}
}

Here The srcATop blend mode replaces any color that your child widget painted with the shader color.

Now wrap our widget that needs to show shimmer with theShimmerLoading

ShimmerLoading(
isLoading: isLoading,
child: FacultyTile(
isLoading:isLoading,
faculty: isLoading?Faculty():facultys[index]),
)

Now our widgets are showing the Shader when it's loading.

Oh, that's cool! wait.. wait but there is a problem with it, each shape in the UI displays a new version of the the shader. We need to display the Shimmer as looks like an entire shimmering surface for the whole screen.

we can just break down the things,

We need to show a single shimmer for the entire screen, but instead of taking the shimmer the entire screen, we need to paint only the place where the widget wrapped with the ShimmerLoading appear.

Let's look at how we can achieve this.

Create a big shimmer

Create a stateful widget called CustomShimmer that takes in a LinearGradient and provides access to its descendants to its state . This widget includes methods to access the gradient, size of CustomShimmerState 's RenderBox , also provide a method for the descendants to find their position within the CustomShimmerState 's RenderBox .

A RenderBox is a rectangular area of the screen where rendering occurs, and it's associated with a widget in the widget tree

class CustomShimmer extends StatefulWidget {
static CustomShimmerState? of(BuildContext context) {
return context.findAncestorStateOfType<CustomShimmerState>();
}

static const _shimmerGradient = LinearGradient(
colors: [
Color(0xFFEBEBF4),
Color(0xFFF4F4F4),
Color(0xFFEBEBF4),
],
stops: [
0.1,
0.3,
0.4,
],
begin: Alignment(-1.0, -0.3),
end: Alignment(1.0, 0.3),
tileMode: TileMode.clamp,
);

const CustomShimmer({
super.key,
this.linearGradient = _shimmerGradient,
this.child,
});

final LinearGradient linearGradient;
final Widget? child;

@override
CustomShimmerState createState() => CustomShimmerState();
}

class CustomShimmerState extends State<CustomShimmer> {

LinearGradient get gradient => LinearGradient(
colors: widget.linearGradient.colors,
stops: widget.linearGradient.stops,
begin: widget.linearGradient.begin,
end: widget.linearGradient.end
);

bool get isSized {
if (context.findRenderObject() == null) {
return false;
}
return (context.findRenderObject() as RenderBox).hasSize;
}

Size get size {
if (context.findRenderObject() == null) {
return const Size(0, 0);
}
return (context.findRenderObject() as RenderBox).size;
}

Offset getDescendantOffset({
required RenderBox descendant,
Offset offset = Offset.zero,
}) {
final shimmerBox = context.findRenderObject() as RenderBox;
return descendant.localToGlobal(offset, ancestor: shimmerBox);
}

@override
Widget build(BuildContext context) {
return widget.child ?? const SizedBox();
}
}

Let's look at what happens here

static CustomShimmerState? of(BuildContext context) {
return context.findAncestorStateOfType<CustomShimmerState>();
}

if you need to access the state of a CustomShimmer widget from some other part of your app, you can use this method to find and get that state. It's a way to reach out and interact with the state of a specific widget, even if it's not directly connected to the current part of the app where you're calling the method.

bool get isSized {
if (context.findRenderObject() == null) {
return false;
}
return (context.findRenderObject() as RenderBox).hasSize;
}

return true if the CustomShimmerState RenderBox undergone the layout.

Size get size {
if (context.findRenderObject() == null) {
return const Size(0, 0);
}
return (context.findRenderObject() as RenderBox).size;
}

This should return the size of CustomShimmerState RenderBox if it has undergone layout, else it returns a ‘0’ size.

Offset getDescendantOffset({
required RenderBox descendant,
Offset offset = Offset.zero,
}) {
final shimmerBox = context.findRenderObject() as RenderBox;
return descendant.localToGlobal(offset, ancestor: shimmerBox);
}

This was an important method in the class, where it returns the exact offset of the descendant to paint the shimmer.

This function takes two required parameters and one optional parameter:

  • descendant: A required parameter representing the RenderBox whose offset you want to calculate.
  • offset: An optional parameter representing any additional offset to be applied to the calculated position. The default value is Offset.zero, meaning no additional offset is initially applied.

The function utilizes the RenderBox API to perform this calculation. Here's a step-by-step breakdown:

  1. final shimmerBox = context.findRenderObject() as RenderBox;: This line retrieves the RenderBox associated with the current widget's BuildContext. It assumes that the widget containing this function has a valid context and that it has been laid out.
  2. descendant.localToGlobal(offset, ancestor: shimmerBox);: This line calculates the global position of the descendant RenderBox relative to the shimmerBox (assumed ancestor).

You can check more about localToGlobal method from here.

Your ShimmerLoading widgets now display a shared gradient that takes up all of the space within the CustomShimmer widget.

Oh great at last we successfully created a beautiful shimmer effect, write?.

did we miss something?

Adding Animation to Shimmer

To create the shimmer effect, it’s essential to animate the gradient, simulating a shining appearance. The LinearGradient in Flutter provides a useful property called transform, allows us to alter the gradient’s appearance. This transformation can be applied, for instance, to achieve horizontal movement. The transform property takes a GradientTransform instance.

To create a horizontal sliding effect, a custom class named _SlidingGradientTransform can be defined. This class should implement the GradientTransform interface to control the transformation and achieve the desired appearance.

class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform({
required this.slidePercent,
});

final double slidePercent;

@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}

The gradient slidePercent changes over time to create the animation effect, to update the slidePercent we use an AnimationController.

class CustomShimmerState extends State<CustomShimmer>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;

@override
void initState() {
super.initState();

_shimmerController = AnimationController.unbounded(vsync: this)
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
}

@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}

Add _SlidingGradientTransform to the LinearGradient transform by using _shimmercontroller value as the slidePercent.

 LinearGradient get gradient => LinearGradient(
colors: widget.linearGradient.colors,
stops: widget.linearGradient.stops,
begin: widget.linearGradient.begin,
end: widget.linearGradient.end,
transform:
_SlidingGradientTransform(slidePercent: _shimmerController.value),
);

The gradient now animates, but your individual ShimmerLoading widgets don’t repaint themselves as the gradient changes. Therefore, it looks like nothing is happening.

Expose the _shimmerController from ShimmerState as a Listenable.

Listenable get shimmerChanges => _shimmerController;

In ShimmerLoading, listen for changes to the ancestor ShimmerState’s shimmerChanges property, and repaint the shimmer gradient.

class _ShimmerLoadingState extends State<ShimmerLoading> {
Listenable? _shimmerChanges;

@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_shimmerChanges != null) {
_shimmerChanges!.removeListener(_onShimmerChange);
}
_shimmerChanges = Shimmer.of(context)?.shimmerChanges;
if (_shimmerChanges != null) {
_shimmerChanges!.addListener(_onShimmerChange);
}
}

@override
void dispose() {
_shimmerChanges?.removeListener(_onShimmerChange);
super.dispose();
}

void _onShimmerChange() {
if (widget.isLoading) {
setState(() {
});
}
}
}

Oh, that’s great you created a beautiful loading effect for your application.

Conclusion

At last, we give a professional touch to our application😋.

I have already provided most of the sample code for the session, but for the end of the article I initially planned to give a complete code for the CustomShimmer and ShimmerLoading widget that will make things much easier for you. then I realize if I do that you just copy and paste the last two code blocs and tada! your app will get the shimmer effect, But it’s better if you have gradual control over how things are working in the background.

Keep this article as a reference point and enjoy your coding 🥳.

Reference:

https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html

--

--