Creating Reusable Custom Widgets in Flutter


Everything’s a widget in Flutter… so wouldn’t it be nice to know how to make your own? There are several methods to create custom widgets, but the most basic is to combine simple existing widgets into the more complex widget that you want. This is called composition.

In this tutorial, you’ll learn how to compose a custom widget that you can reuse anywhere. These are the specific skills you’ll learn how to do:

  • Design the widget’s UI
  • Build your design using existing widgets
  • Plan and implement how users interact with the widget

Getting Started

Download the project by clicking the Download Materials button at the top or bottom of the page.

This article uses Android Studio, but Visual Studio Code will work fine as well.

You’ll make a music-playing app called Classical. It only plays one song, but that’s OK because the song is so great you won’t ever want to listen to anything else. :]

Here’s how the app will look when you’re done:

Classical app's finished look

The audio player control at the bottom is the custom widget that you’ll make.

Open the starter project by navigating to the starter folder and clicking Get dependencies when Android Studio prompts you to do so.

The starter project already includes some code so you can finish this project in a single tutorial. If you are curious about the app architecture, check out the article State Management With Provider.

Run the app now and you’ll see this:

Classical app's starter view

It’s time to start composing your widget so your users can listen to some delightful music.

Refactoring UI Layouts

As you probably know, Flutter’s UI layout consists of a tree of widgets.

Widget tree

Each leaf in the tree is a widget. Each branch of the tree is a widget. The whole UI itself is also just a widget. That’s what composition is all about: widgets made of widgets all the way down to the smallest components.

The code for the widget tree can get pretty nested. To make your layout code more readable and maintainable, you should factor out groups of widgets into their own standalone widget classes.

Extracting Widgets

In the lib folder, open main.dart. Find the MyApp widget, which looks like this:


class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
        body: SafeArea(
          ...
        ),
    );
  }
}

Even though MyApp is quite simple already, you can break it down still further. This is a good opportunity to learn about refactoring and using Android Studio’s tools to extract widgets.

Put your cursor on Stack and right-click to show the context menu. Then choose Refactor ▸ Extract ▸ Extract Flutter Widget….

This is the body of Scaffold, so name it BodyWidget.


class BodyWidget extends StatelessWidget {
  const BodyWidget({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        // ...
      ],
    );
  }
}

Android Studio automatically created a new widget from Stack and its descendant widgets. That’s it. You’re finished. Now you know how to make custom widgets in Flutter. Thanks for reading. Come again in the next tutorial for more great content from raywenderlich.com.

Just kidding. :] There’s more to this article ahead. But in all seriousness, it really is that easy to create new widgets.

You could put BodyWidget into its own file and use it in another part of this app or even another app. While this widget itself isn’t particularly interesting, the audio player widget that you’ll build next is.

Note: When you extracted the Flutter widget, you may have noticed another choice in the context menu called Method. This would have returned the Stack widget tree from a method within MyApp.

While this is fine, there are a number of advantages to extracting as a widget rather than a method or function. The main advantage for the purpose of this article is that you can reuse extracted widgets.

Types of Custom Widgets

This article focuses on the easiest way to make custom widgets: composition, or building complex widgets by combining simpler widgets. However, it’s worth mentioning a couple of other ways to make custom widgets.

If you can’t get the widget you want by combining other widgets, you can draw it on a canvas that Flutter provides. You do this using CustomPainter. Read Drawing Custom Shapes With CustomPainter in Flutter for a great example of how to do that.

If you want to go really low level, it’s also possible to make widgets the same way that the Flutter framework does it: by using RenderObjects. The best way to learn about this is to explore the Flutter source code of a widget similar to the one you want to make. Check out Flutter Text Rendering for a real-life example of making a widget from scratch.

It’s time to get down to work. This article will take you step-by-step through everything you need to do to create your own custom widgets. Here are the steps you’ll take:

  1. Design your widget
  2. Decompose the design
  3. Build the basic widget
  4. Customize the look
  5. Determine the user interaction
  6. Define the parameters
  7. Implement the parameters
  8. Test the widget
  9. Share your widget with the world

In the next four steps, you’ll determine how the user interface will look.

Designing Your Widget

It’s helpful to have a visual representation of the widget you want in your mind. Sketch it out on paper or use design software to draw it.

You can also borrow design ideas from others. Just because you’re a developer, doesn’t mean you can’t learn to be a great designer as well. If you’re into podcasts, check out Design Advice for Engineers to further develop your skills in this area.

For an audio player control widget, MediaElement.js is a good solid place to start:

MediaElement.js Audio Player's UI design showing a play button, the current time, a time slider, the total time and volume control

The volume control is not important for this tutorial, so crop it out:

MediaElement.js without volume control

Decomposing the Design

Once you have the design you want, identify which smaller widgets you can use to build it. You should be able to get something close with IconButton, Slider, Container and a couple of Text widgets.

Audio player decomposed into widgets

Oh, yes, they’re laid out in a row, so you’ll need a Row widget, too.

Building the Basic Widget

Create a new file by right-clicking the lib folder and choosing New ▸ File. Name it audio_widget.dart.

Then enter the following code:


import 'package:flutter/material.dart';

class AudioWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Row(
        children: [
          IconButton(icon: Icon(Icons.play_arrow)),
          Text('00:37'),
          Slider(value: 0),
          Text('01:15'),
        ],
      ),
    );
  }
}

Note the Container, Row, Button, Text and Slider widgets.

Back in lib/main.dart, scroll to the bottom of the file and delete the line that says TODO delete this line. Then uncomment the line that says TODO uncomment this line.

Add the import at the top:


import 'audio_widget.dart';

Build and run the app.

This gives you the following. If you ignore the fact that it’s sitting on Beethoven’s chest, it already looks a lot like an audio player control.

Audio widget misplaced about halfway up the app view

Customizing the Look

To make the control look more like the MediaElement.js audio player, you need to make a few adjustments.

Open lib/audio_widget.dart again.

The first thing to do is give the widget a fixed height so it doesn’t take up the whole screen. Add the following line to the Container widget before its child parameter.


height: 60,

This is where hot reload shines. Press the yellow Lightning button in Android Studio to get an instant update.

That’s better. Now it’s at the bottom where it’s supposed to be:

Audio widget with a fixed height

The button looks a little too dark. That’s because it needs a function for its onPressed callback to enable it. Add onPressed: (){}, to IconButton so it looks like this:


IconButton(
  icon: Icon(Icons.play_arrow),
  onPressed: (){},
),

Do a hot reload.

The Play button is brighter now:

Audio widget with white button

There’s quite a bit you can customize about the Slider widget. Add the following parameters:


Slider(
  value: 0.5,
  activeColor: Theme.of(context).textTheme.bodyText2.color,
  inactiveColor: Theme.of(context).disabledColor,
  onChanged: (value){},
),

Here are some notes about this code:

  • A value of 0.5 puts the slider thumb in the middle.
  • Rather than hardcoding the active and inactive colors, getting the colors from the theme makes this widget work in both dark and light modes. That’s a win for reusability.
  • Giving onChanged a value enables the slider. You’ll add more code here later.

Do a hot reload.

Audio widget with slider and a lot of empty space on the right

There’s too much empty space on the right. Slider can be any length, so wrap it with Expanded. With your cursor on Slider, press Option-Return on a Mac or Alt-Enter on a PC. Choose Wrap with widget in the context menu and change widget to Expanded.


Expanded(
  child: Slider(...),
)

Do a hot reload.

Audio widget with expanded wrapped slider reaching the right edge of the screen

Looks like it needs a little padding on the right. Add SizedBox(width: 16), to the end of the list of Row children like so:


IconButton(...),
Text(...),
Slider(...),
Text(...),
SizedBox(width: 16),

Do a hot reload.

Audio widget with padding

Great! That looks pretty good for now.

Now that you’ve finished the UI, you need to allow the user to interact with the audio widget. You’ll add these UX features in the next three steps.

Determining the User Interaction

There are four pieces here:

The four parts of the audio widget's UX

  1. Play/Pause button: When a user clicks this, it should alternate between a Play and a Pause icon. When the audio reaches the end of the track, it should also revert to the Play icon. That means there needs to be a way to set the button icon, or maybe the play state.
  2. Current time: The app user doesn’t interact with the current time, but the developer needs to have some way to update it based on whatever audio plugin they’re using.
  3. Seek bar: The developer should be able to update the position based on the elapsed time of the audio that’s playing. The user should also be able to drag it to a new location and have that notify a listener.
  4. Total time: The developer needs to be able to set this based on the audio file length.
Note: This widget will not actually play any audio itself. Rather, it’s a skin that a developer can use with any audio player plugin. They’ll just rebuild the widget whenever the audio state changes. The Classical app uses the audioplayers plugin.

Defining the Parameters

Imagine that you’re a developer using this widget. How would you want to set the values?

This would be one reasonable way to do it:


AudioWidget(
  isPlaying: false,
  onPlayStateChanged: (bool isPlaying) {},
  currentTime: Duration(),
  onSeekBarMoved: (Duration newCurrentTime) {},
  totalTime: Duration(minutes: 1, seconds: 15),
),

Here’s what this code is doing:

  • isPlaying: This allows you to toggle the Play/Pause button icon.
  • onPlayStateChanged: The widget notifies you when the user presses the Play/Pause button.
  • currentTime: By using Duration here, rather than String or Text, you don’t need to worry about setting the current time text and the Slider thumb position separately. The widget will handle both of these.
  • onSeekBarMoved: This updates you when the user chooses a new location.
  • totalTime: Like currentTime, this can also be a Duration.

This is the tactic you’ll use in this tutorial.

Implementing the Parameters

There are a handful of sub-steps necessary to implement your plan above.

Converting to StatefulWidget

You originally made a stateless widget, but you need to convert it to StatefulWidget because you now have to keep track of the Slider state internally.

In lib/audio_widget.dart, put your cursor on the AudioWidget class name. Press Option-Return on a Mac or Alt-Enter on a PC to show the context menu. Choose Convert to StatefulWidget. You’ll see something similar to the following:


class AudioWidget extends StatefulWidget {
  @override
  _AudioWidgetState createState() => _AudioWidgetState();
}

class _AudioWidgetState extends State<AudioWidget> {
  @override
  Widget build(BuildContext context) {
    return Container(...);
  }
}

Adding a StatefulWidget Constructor

Now, in AudioWidget (not _AudioWidgetState), add a constructor with the parameters you defined above:


const AudioWidget({
  Key key,
  this.isPlaying = false,
  this.onPlayStateChanged,
  this.currentTime,
  this.onSeekBarMoved,
  @required this.totalTime,
}) : super(key: key);

final bool isPlaying;
final ValueChanged<bool> onPlayStateChanged;
final Duration currentTime;
final ValueChanged<Duration> onSeekBarMoved;
final Duration totalTime;

Here are some things to note:

  • The source code of the standard Flutter widgets is very useful to see how other widgets are built. The Slider widget source code is especially helpful here.
  • All widgets have keys. Watch When to Use Keys to learn more about them.
  • ValueChanged is just another name for Function(T value). This is how you make a parameter with a closure.
  • It wouldn’t make sense to have an audio player without a total time length. The @required annotation is useful to enforce that.

Since totalTime is required now, go to main.dart and add an arbitrary Duration to the AudioWidget constructor.


return AudioWidget(
  totalTime: Duration(minutes: 1, seconds: 15),
);

You’ll hook AudioWidget up to the view model later to get a real audio duration.

Implementing the Play Button

You’ll handle the logic for the Play/Pause button next.

Audio widget with the Play button highlighted in red

You aren’t going to add any internal state for this button. The developer can keep track of the play state based on the audio plugin that’s actually playing the music. When that state changes, the developer can just rebuild this widget with a new value for isPlaying.

To keep the UI layout code clean, build the Play button in its own method. Go back to lib/audio_widget.dart. In _AudioWidgetState, put your cursor on IconButton, right-click and choose Refactor ▸ Extract ▸ Method from the context menu. This time, you’re extracting as a method rather than a widget so that you can keep everything in the state class.

Name the method _buildPlayPauseButton and give it this code:


IconButton _buildPlayPauseButton() {
  return IconButton(
    icon:
    (widget.isPlaying)
        ? Icon(Icons.pause)
        : Icon(Icons.play_arrow),
    color: Colors.white,
    onPressed: () {
      if (widget.onPlayStateChanged != null) {
        widget.onPlayStateChanged(!widget.isPlaying);
      }
    },
  );
}

Here are some notes about the code above:

  • IconButton now chooses an icon based on isPlaying‘s value. Pressing the button will notify anyone listening to onPlayStateChanged about the event.
  • The variables in StatefulWidget are available to the state class by prefixing them with widget.. For example, in _AudioWidgetState you can reference the isPlaying variable of AudioWidget by using widget.isPlaying.

Do a hot restart. A disadvantage of extracting to a method rather than a widget is that hot reload doesn’t work.

Press the Play button now, but there’s no response. That’s because you haven’t hooked up any logic to change the isPlaying value yet. You’ll do that once you’ve implemented all the other widgets.

Implementing the Seek Bar

Do the seek bar next because the current time label depends on it.

Audio widget with the seek bar outlined in red

Add two state variables at the top of _AudioWidgetState:


double _sliderValue;
bool _userIsMovingSlider;

The slider value can be a double from 0.0 to 1.0. Add a method at the bottom of the _AudioWidgetState class to calculate it:


double _getSliderValue() {
  if (widget.currentTime == null) {
    return 0;
  }
  return widget.currentTime.inMilliseconds / widget.totalTime.inMilliseconds;
}

Use milliseconds rather than seconds so the seek bar will move smoothly, rather than hopping from second to second.

When the user is moving the slider manually, you’ll need a method to calculate the current time based on the slider value. Add the following method at the bottom of the _AudioWidgetState class:


Duration _getDuration(double sliderValue) {
  final seconds = widget.totalTime.inSeconds * sliderValue;
  return Duration(seconds: seconds.toInt());
}

Now you can initialize the state variables. Add the following method above build in _AudioWidgetState:


@override
void initState() {
  super.initState();
  _sliderValue = _getSliderValue();
  _userIsMovingSlider = false;
}

This method is only called the first time the widget is built.

When the user is moving the seek bar at the same time that audio is playing, you don’t want _sliderValue to fight against widget.currentTime. The _userIsMovingSlider flag helps you check for that. Apply the flag by adding the following lines inside build before the return statement.


if (!_userIsMovingSlider) {
  _sliderValue = _getSliderValue();
}

Now, extract Slider into a method as you did for IconButton earlier. Put your cursor on Expanded — the parent of Slider — right-click and choose Refactor ▸ Extract ▸ Method from the context menu.

Name the method _buildSeekBar and give it the following code:


Expanded _buildSeekBar(BuildContext context) {
  return Expanded(
    child: Slider(
      value: _sliderValue,
      activeColor: Theme.of(context).textTheme.bodyText2.color,
      inactiveColor: Theme.of(context).disabledColor,
      // 1
      onChangeStart: (value) {
        _userIsMovingSlider = true;
      },
      // 2
      onChanged: (value) {
        setState(() {
          _sliderValue = value;
        });
      },
      // 3
      onChangeEnd: (value) {
        _userIsMovingSlider = false;
        if (widget.onSeekBarMoved != null) {
          final currentTime = _getDuration(value);
          widget.onSeekBarMoved(currentTime);
        }
      },
    ),
  );
}

Here are some things to note:

  1. The user is starting to manually move the Slider thumb.
  2. Whenever the Slider thumb moves, _sliderValue needs to update. This will affect the UI by updating the visual position of the thumb on the slider.
  3. When the user finishes moving the thumb, turn the flag off to start moving it based on the play position again. Then notify any listeners of the new seek position.

Do a hot restart.

Audio widget with a user moving the seek bar

The slider moves now, but the label is still not updating. You’ll address that next.

Implementing the Current Time Label

You can change the current time by changing the constructor value or by moving the slider.

Audio widget with the current time label highlighted in red

Since Slider should always stay in sync with the current time label, use _sliderValue to generate the label string.

Add the following method at the bottom of the _AudioWidgetState class:


String _getTimeString(double sliderValue) {
  final time = _getDuration(sliderValue);

  String twoDigits(int n) {
    if (n >= 10) return "$n";
    return "0$n";
  }

  final minutes = twoDigits(time.inMinutes.remainder(Duration.minutesPerHour));
  final seconds = twoDigits(time.inSeconds.remainder(Duration.secondsPerMinute));

  final hours = widget.totalTime.inHours > 0 ? '${time.inHours}:' : '';
  return "$hours$minutes:$seconds";
}

This method is a modification of the Dart Duration.toString() method.

Next, extract the current time Text widget to a method. In build, put your cursor on the first Text widget, right-click and choose Refactor ▸ Extract ▸ Method from the context menu.

Name the method _buildCurrentTimeLabel and give it the following code:


Text _buildCurrentTimeLabel() {
  return Text(
    _getTimeString(_sliderValue),
    style: TextStyle(
      fontFeatures: [FontFeature.tabularFigures()],
    ),
  );
}

FontFeature requires the dart:ui library, so add the following import at the top of the file:


import 'dart:ui';

Using FontFeature.tabularFigures() ensures that the digits will use a monospaced width. This keeps the Text width from jumping around. Read about Font Features in Flutter to learn more.

Do a hot restart.

Audio widget with current time updating as the seek bar moves

Now, the current time label updates when you move the seek bar thumb.

Implementing the Total Time Label

Last of all is the total time label on the far right.

Audio widget with the total time label outlined in red

Extract the total time Text widget to its own method. As before, in build, put your cursor on the last Text widget, right-click and choose Refactor ▸ Extract ▸ Method from the context menu.

Name the method _buildTotalTimeLabel and give it the following code:


Text _buildTotalTimeLabel() {
  return Text(
    _getTimeString(1.0),
  );
}

The total time is when the slider is all the way at the right, which is a slider value of 1.0. Thus, you can use _getTimeString() again to generate the label string.

Do a hot restart.

It looks the same as before because the totalTime argument is Duration(minutes: 1, seconds: 15), which you set previously in main.dart.

Audio widget including current time

Great! You now have your own custom widget composed completely of existing Flutter widgets.

In the last two steps, you’ll finalize your widget for production.

Testing the Widget

Widget testing is an important part of creating custom widgets. To keep this article a manageable size, it won’t cover widget testing, but you should read An Introduction to Widget Testing in the Flutter documentation and Widget Testing With Flutter: Getting Started here on raywenderlich.com.

For now, you’ll just test that AudioWidget works by hooking it up to an audio plugin. The view model in the starter project is all ready to communicate with your new widget.

In lib/main.dart, delete the entire AudioPlayer class, located at the bottom of the file, then add the following code:


class AudioPlayer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ViewModelBuilder<AudioViewModel>.reactive(
      viewModelBuilder: () => AudioViewModel(),
      onModelReady: (model) => model.loadData(),
      builder: (context, model, child) => AudioWidget(
        isPlaying: model.isPlaying,
        onPlayStateChanged: (bool isPlaying) {
          model.onPlayStateChanged(isPlaying);
        },
        currentTime: model.currentTime,
        onSeekBarMoved: (Duration newCurrentTime) {
          model.seek(newCurrentTime);
        },
        totalTime: model.totalTime,
      ),
    );
  }
}

AudioWidget is the most important part here. It gets its state from model and rebuilds whenever the values there change. It also updates model when the user presses the Play/Pause button or moves the seek bar.

Do a hot reload, press the Play button and enjoy the concert.

Completed Classical app

Here is what it looks like in action:

Audio widget with music

Sharing Your Widget With the World

Now that you have a working version of AudioWidget, you or anyone else can use it simply by copying audio_widget.dart into a project. You can make it even easier for other people to use it by sharing it on Pub, the central repository for Flutter and Dart packages.

Here are a few general guidelines for adding a Pub package:

  • Start a new Flutter project in Android Studio and choose Flutter Package for the project type.
  • Put your custom widget in the lib folder.
  • Add a folder named example to the project root. In there, add a Flutter app that demonstrates how to use your widget. The example project’s pubspect.yaml imports the widget using path: ../. Find other developers’ examples on GitHub to see how they did it. Most Pub packages have links to their GitHub repos.
  • Make a GitHub repository for your own project. Make sure all your public methods and parameters have comment documentation.
  • Read Developing Packages & Plugins and Publishing Packages.
  • Once you’ve set everything up, running pub publish from the project root is how you publish your package. First, however, you should test it with pub publish --dry-run.

Where to Go From Here?

Download the final project using the Download Materials button at the top or bottom of this tutorial.

If you want to improve the widget, here are a few ideas:

  • Add more constructor parameters to allow developers to further customize how the widget looks and behaves.
  • Support playing audio from remote sources by allowing the Play button to show a circular progress indicator while the audio file is downloading or buffering.
  • Replace Slider with a CustomPainter where you draw the play progress and buffered content separately. Refer to RangeSlider for ideas.

Here are a couple good videos to learn more about building custom widgets:

If you have any comments or questions, please leave them in the forum discussion below.

Source link

Leave a Reply