Pull-to-refresh in Flutter

Flutter does not have a good way to handle this, so I implemented it. Very smooth animations with lots of control.

Sun, July 24 2022

jake hockey

Jake Landers

Developer and Creator

hey

Pull to refresh is one of those things that is easy to implement, but hard to make look good. There are a few packages that give you this functionality and do it quite well, but I believe there is a lot to be learned by implementing it yourself. Plus, if you are like me and implement your own Navigation Bar and drawer, this solution can be adapted to any use case.

Dependencies

There is only one optional dependency, which is the amazing Sprung package. Seriously. If I could only have one package this would be it. It provides stunning animation curves that I use ANYWHERE I can specify the animation curve. Seriously, the Sprung.overdampened is amazing.

1dependencies: 2 flutter: 3 sdk: flutter 4 sprung: ^3.0.0 5
NOTE:

This is a more fancy note that contains some highlighting lets see how this note handles that. also, lets add a link because I will also want to handle that.

Beginning Code

To start, we need to set up our stateful widget and define a few internal variables.

1class Refreshable extends StatefulWidget { 2 const Refreshable({Key? key}) : super(key: key); 3 4 5 _RefreshableState createState() => _RefreshableState(); 6} 7 8class _RefreshableState extends State<Refreshable> { 9 late ScrollController _controller; 10 11 bool _canLoad = false; 12 bool _isLoading = false; 13 double _loadAmount = 0; 14 double _scrollAmount = 0; 15 16 17 void initState() { 18 super.initState(); 19 _controller = ScrollController(); 20 } 21 22 23 void dispose() { 24 // memory management 25 _controller.dispose(); 26 super.dispose(); 27 } 28} 29

Then, we can define a pleasant view to add our scroll to refresh to. This is a basic view which has a title and a list of cells that will change color while the view is loading.

1Scaffold( 2 backgroundColor: const Color.fromRGBO(245, 242, 247, 1), 3 body: Stack( 4 children: [ 5 ListView( 6 physics: const AlwaysScrollableScrollPhysics(), 7 controller: _controller, 8 children: [ 9 // animated padding to animate changes. This allows 10 // the entire view to remain scrolled down while the 11 // function is loading in new data 12 Padding( 13 padding: EdgeInsets.only(top: _scrollAmount), 14 child: Column( 15 crossAxisAlignment: CrossAxisAlignment.start, 16 children: [ 17 const Padding( 18 padding: EdgeInsets.all(16), 19 child: Text( 20 "Refreshable", 21 style: TextStyle( 22 fontWeight: FontWeight.w700, 23 fontSize: 32, 24 ), 25 ), 26 ), 27 // can control what gets shown for loading state 28 // here red cells get shown when the view 29 // is actively loading something 30 for (int i = 0; i < 20; i++) 31 Padding( 32 padding: const EdgeInsets.symmetric(horizontal: 16.0), 33 child: Column( 34 children: [ 35 Material( 36 shape: ContinuousRectangleBorder( 37 borderRadius: BorderRadius.circular(40)), 38 color: _isLoading 39 ? Colors.red 40 : Colors.black.withOpacity(0.1), 41 child: const SizedBox( 42 height: 100, 43 width: double.infinity, 44 ), 45 ), 46 const SizedBox(height: 16), 47 ], 48 ), 49 ), 50 ], 51 ), 52 ), 53 ], 54 ), 55 ], 56 ), 57 ); 58

Scroll Code

In order to get the scroll offset for a view, we need to add a listener to the scroll controller. This is easily done with adding a simple modifier on the scroll controller, with the offset retreived from the controller itself.

We can then add a few things to this controller:

  1. Detect when the user is scrolling in the downwards direction
  2. set the load progress to a fraction of this scrolling
  3. determine when the user has pulled down far enough and allow the view to refresh
1 2 void initState() { 3 super.initState(); 4 5 // written this way incase this view 6 // gets encapsulated, and want other 7 // unrelated scollcontroller functionality 8 // outside this class 9 _controller = ScrollController(); 10 11 // attach a listener onto the scroll controller 12 // so we have access to the current offset 13 _controller.addListener(() { 14 // check for scrolling down 15 if (_controller.offset < 0) { 16 // set the loadAmount, this gets represented 17 // in the progress indicator between 0 and 1 18 // with 0 being invisible and 1 being a full circle 19 setState(() { 20 _loadAmount = -0.2 + -(_controller.offset * 0.012); 21 }); 22 // if fully scrolled down, let the view know it can 23 // load when the user releases the screen 24 if (_loadAmount >= 1) { 25 setState(() { 26 _canLoad = true; 27 }); 28 } 29 } 30 }); 31 } 32

After this, the ListView needs to be wrapped with a NotificationListener to listen for Scroll changes. This uses an interesting hack I found that allows you to determine when the user releases the screen as opposed to when the view stops scrolling. This is essential as we do not want the children of the widget sliding all the way up, the view then detects the view has stopped scrolling, then animate back upwards to reveal the loading indicator. There may very well be a better way of accomplishing this behavior, but this seems to work well for me.

Once the user has stopped scrolling and our scroll controller has told us it was a large enough scroll, we can call our update function to fetch our async data.

1NotificationListener( 2 onNotification: (ScrollNotification notification) { 3 // weird hack to determine still dragging 4 // may be better way to detect, but this works well 5 // does NOT detect if still scrolling, only if user 6 // physically has finger on the screen 7 if (!notification.toString().contains("DragUpdateDetails")) { 8 // user released the screen, animate the position change 9 if (_scrollAmount == 0 && _canLoad) { 10 // set the padding to be any value you want 11 setState(() { 12 _scrollAmount = 50; 13 }); 14 // call the async function that resets the values 15 _function(); 16 } 17 } 18 return true; 19 }, 20 child: ListView(...), 21) 22

The async function also contains the necessary code to return our view to the beginning state, to allow for the entire process to repeat.

1 Future<void> _function() async { 2 setState(() { 3 _isLoading = true; 4 }); 5 // put any Future<void> async function that calls data here 6 await Future.delayed(const Duration(seconds: 2)); 7 setState(() { 8 // reset all of the variables, this is essential 9 // for resetting functionality and returning 10 // screen to starting state 11 _isLoading = false; 12 _canLoad = false; 13 _scrollAmount = 0; 14 }); 15 } 16

Lastly, we need a way to hold the view stack above the indicator, and to add the indicator ourselves. To hold the view up, we will use an AnimatedPadding to animate the transition when we set the value to 50 in the code above. To show the progress of the scroll and of the function, we will use the CircularProgressIndicator as it has two modes. One to show linear load progress, and another to show indeterminate progress. We can use both of these contructors to show the two different load states present. One to show how much further the user has to drag down, and the other to show that the function is being called.

We can replace the padding on the Column with

1AnimatedPadding( 2 duration: const Duration( 3 milliseconds: 4 800), // probably do not change this value, I have found it works well 5 curve: Sprung 6 .overDamped, // amazing sping function that avoids jitters when moving 7 padding: EdgeInsets.only(top: _scrollAmount), 8 child: Column(...), 9), 10

Then we can add the indicator at the bottom of the Stack like shown:

1Padding( 2 padding: EdgeInsets.only( 3 top: MediaQuery.of(context).padding.top + 4 (Platform.isIOS ? 0 : 10)), 5 child: Align( 6 alignment: Alignment.topCenter, 7 child: _canLoad && _scrollAmount != 0 8 ? const CircularProgressIndicator() 9 : CircularProgressIndicator( 10 value: _loadAmount, 11 ), 12 ), 13), 14

Notes

There was an issue where scrolling upwards after completed scroll would not provide the desired effect, it has been fixed in the code on github. It uses an extra check in the notification observer and changes the condition for showing the continuous loading indicator vs the deterministic one.

Comments