DEV Community

Rody Davis
Rody Davis

Posted on • Originally published at rodydavis.com on

Building A Piano with Flutter

TLDR #

You can find the final source code here.

This piano uses only 5032 bytes of Dart Code!

Winner of the Flutter Create Contest and you can see the certificate here!

What you will learn #

  • Working with Dark Mode
  • Forcing app to be in landscape
  • Working with custom files bundled with the app
  • Working with midi and sounds in flutter
  • Working with StatefulWidget
  • Using SafeArea and Semantics
  • Building an app with minimal code

What you need #

  • Flutter SDK Installed (More Info)
  • A .sf2 SoundFont File like this one
  • Physical iOS device (iOS Simulator does not work with this plugin for playing the sounds) or Android Emulator/Device

Setting Up #

You can either create a new project with Android Studio or VSCode using the GUI or navigate to the location you want your project and using this command in the terminal: lutter create -i swift -a kotlin flutter_piano. Make sure to include Swift and Kotlin Support!

Now that you have your project created it should look like this.

app-icon

Let's start by adding some dependencies to our `pubspec.yaml'


dependencies: flutter: sdk: flutter tonic: ^0.2.3 flutter_midi: ^0.1.1+3 cupertino_icons: ^0.1.2

and add the .sf2 file


assets: - assets/sounds/Piano.sf2

If you haven't already create a new folder at the top of your project call assets and a subfolder called sounds and place the .sf2 file there and make sure it is named Piano.sf2

Because our app will only work in landscape we need to update those settings as well.

navigate to the /android/app/src/main/AndroidManifest.xml and add this line inside <activity in the <application:

`
android:screenOrientation="landscape"

`

Example:


<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rodydavis.flutter_piano"> <!-- io.flutter.app.FlutterApplication is an android.app.Application that calls FlutterMain.startInitialization(this); in its onCreate method. In most cases you can leave this as-is, but you if you want to provide additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> <application android:name="io.flutter.app.FlutterApplication" android:label="flutter_piano" android:icon="@mipmap/ic_launcher"> <activity android:name=".MainActivity" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:screenOrientation="landscape" android:windowSoftInputMode="adjustResize"> <!-- This keeps the window background of the activity showing until Flutter renders its first frame. It can be removed if there is no splash screen (such as the default splash screen defined in @style/LaunchTheme). --> <meta-data android:name="io.flutter.app.android.SplashScreenUntilFirstFrame" android:value="true" /> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application></manifest>

navigate to /ios/Runner/info.plist and change:

`
UISupportedInterfaceOrientations

UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight

`

Now we can start with the UI! When you run the application now it should start in landscape!

landscape

Step 1 #

To make it eaiser to read lets remove the comments. Use "find and replace" and search for \/\/.*

comments

choose the "select all occurrances" button and hit backspace to delete.

select all

Hit save and you should see the code format for you.

The 'main.dart' file should look like this:


import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); }}class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState();}class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.display1, ),], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), ); }}

Step 2 #

Delete the MyHomePage widget so you are left with this.


import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); }}

You should get an error and thats ok, we will fix that next.

Replace MyHomePage(title: 'Flutter Demo Home Page') with a Scaffold()


import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: Scaffold(), ); }}

Step 3 #

Change MyApp to a StatefulWidget. You can do this quickly by selecting MyApp and choose "Convert to StatefulWidget" with the helper.

convert

It should look like this now:


import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: Scaffold(), ); }}

Step 4 #

Change the theme to dark. You can do this by setting the ThemeData in MaterialApp

change:


theme: ThemeData( primarySwatch: Colors.blue, ),

to this


theme: ThemeData.dark(),

Add and AppBar to the Scaffold

appBar: AppBar(title: Text("Flutter Piano")),


import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), ), ); }}

Now build and run your app, it should look like this.

dark-mode

Step 5 #

We need to add some imports to the top:


import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';

If you get an error make sure they are added in the pubspec.yaml from earlier, then restart the app. Be sure to run flutter packages get everytime you add a dependency.

Now we can add out initState() to our app.


@override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); }

Run the app and make sure you do not get any errors. If you are running this on the iOS Simulator you will get the following error:

Could Not Load Midi on this Device. (Cannot run on simulator), have you included the sound font?

It is ok for developing the UI but once we start with the midi you will need to plug in a real device.

Your code so far should look like this:


import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), ), ); }}

Step 6 #

To make Flutter development faster we start with containers and colors so we can make sure everything is the right size.

Lets start by adding a Drawer with a ListView to our Scaffold.


home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), drawer: Drawer(child: SafeArea(child: ListView(children: <Widget>[]))), ),

You should now get a menu icon that when you press looks like this.

menu

Now lets add a ListView that scrolls Horizontially to the body of the Scaffold


body: ListView.builder( itemCount: 7, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { return Container(); }, )

We need 7 itemCount for 7 octaves on the Piano.


import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), drawer: Drawer(child: SafeArea(child: ListView(children: <Widget>[]))), body: ListView.builder( itemCount: 7, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { return Container(); }, )), ); }}

Step 7 #

Now we need to build the octave section that will be repeated. Since every octave is identical we can repeat the octaves with minor adjustments.

Lets add some parameters for use to define for our UI. Add these underneath the initState function.


double get keyWidth => 80 + (80 * _widthRatio);double _widthRatio = 0.0;bool _showLabels = true;

We will use these to dynamily update the keys.

Under the itemBuilder lets define which octave we are working with by adding:

final int i = index * 12;

Our code should look like this now:


import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); } double get keyWidth => 80 + (80 * _widthRatio); double _widthRatio = 0.0; bool _showLabels = true; @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), drawer: Drawer(child: SafeArea(child: ListView(children: <Widget>[]))), body: ListView.builder( itemCount: 7, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { final int i = index * 12; return Container(); }, )), ); }}

Step 8 #

Now we need to add a Stack for our octave:


return SafeArea( child: Stack(children: <Widget>[Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ _buildKey(24 + i, false), _buildKey(26 + i, false), _buildKey(28 + i, false), _buildKey(29 + i, false), _buildKey(31 + i, false), _buildKey(33 + i, false), _buildKey(35 + i, false),]), Positioned( left: 0.0, right: 0.0, bottom: 100, top: 0.0, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: <Widget>[Container(width: keyWidth * .5), _buildKey(25 + i, true), _buildKey(27 + i, true), Container(width: keyWidth), _buildKey(30 + i, true), _buildKey(32 + i, true), _buildKey(34 + i, true), Container(width: keyWidth * .5),])), ]), );

Here we have defined which midi notes are played for each octave.

Now add the function _buildKey underneath our build function.


Widget _buildKey(int midi, bool accidental) { if (accidental) { return Container( width: keyWidth, color: Colors.black, margin: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: keyWidth * .1), child: Material( elevation: 6.0, borderRadius: borderRadius, shadowColor: Color(0x802196F3), )); } return Container( width: keyWidth, color: Colors.white, margin: EdgeInsets.symmetric(horizontal: 2.0)); }

Also add borderRadius to the bottom of main.dart


const BorderRadiusGeometry borderRadius = BorderRadius.only( bottomLeft: Radius.circular(10.0), bottomRight: Radius.circular(10.0));

Your app should look like this:

keys

Your code should look like this:


import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); } double get keyWidth => 80 + (80 * _widthRatio); double _widthRatio = 0.0; bool _showLabels = true; @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), drawer: Drawer(child: SafeArea(child: ListView(children: <Widget>[]))), body: ListView.builder( itemCount: 7, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { final int i = index * 12; return SafeArea( child: Stack(children: <Widget>[Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ _buildKey(24 + i, false), _buildKey(26 + i, false), _buildKey(28 + i, false), _buildKey(29 + i, false), _buildKey(31 + i, false), _buildKey(33 + i, false), _buildKey(35 + i, false),]), Positioned( left: 0.0, right: 0.0, bottom: 100, top: 0.0, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: <Widget>[Container(width: keyWidth * .5), _buildKey(25 + i, true), _buildKey(27 + i, true), Container(width: keyWidth), _buildKey(30 + i, true), _buildKey(32 + i, true), _buildKey(34 + i, true), Container(width: keyWidth * .5),])), ]), ); }, )), ); } Widget _buildKey(int midi, bool accidental) { if (accidental) { return Container( width: keyWidth, color: Colors.black, margin: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: keyWidth * .1), child: Material( elevation: 6.0, borderRadius: borderRadius, shadowColor: Color(0x802196F3), )); } return Container( width: keyWidth, color: Colors.white, margin: EdgeInsets.symmetric(horizontal: 2.0)); }}const BorderRadiusGeometry borderRadius = BorderRadius.only( bottomLeft: Radius.circular(10.0), bottomRight: Radius.circular(10.0));

Step 9 #

Time to add midi by adding the following import to the top of the file:


import 'package:tonic/tonic.dart';

In the -buildKey function you can add this line:


final pitchName = Pitch.fromMidiNumber(midi).toString();

We can also create the piano key itself underneath it:


final pianoKey = Stack( children: <Widget>[Semantics( button: true, hint: pitchName, child: Material( borderRadius: borderRadius, color: accidental ? Colors.black : Colors.white, child: InkWell( borderRadius: borderRadius, highlightColor: Colors.grey, onTap: () {}, onTapDown: (_) => FlutterMidi.playMidiNote(midi: midi), ))), Positioned( left: 0.0, right: 0.0, bottom: 20.0, child: _showLabels ? Text(pitchName, textAlign: TextAlign.center, style: TextStyle( color: !accidental ? Colors.black : Colors.white)) : Container()),], );

Remove the color from the container and replace it with child: pianoKey,


if (accidental) { return Container( width: keyWidth, margin: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: keyWidth * .1), child: Material( elevation: 6.0, borderRadius: borderRadius, shadowColor: Color(0x802196F3), child: pianoKey)); } return Container( width: keyWidth, child: pianoKey, margin: EdgeInsets.symmetric(horizontal: 2.0));

The complete function should look like this:


Widget _buildKey(int midi, bool accidental) { final pitchName = Pitch.fromMidiNumber(midi).toString(); final pianoKey = Stack( children: <Widget>[Semantics( button: true, hint: pitchName, child: Material( borderRadius: borderRadius, color: accidental ? Colors.black : Colors.white, child: InkWell( borderRadius: borderRadius, highlightColor: Colors.grey, onTap: () {}, onTapDown: (_) => FlutterMidi.playMidiNote(midi: midi), ))), Positioned( left: 0.0, right: 0.0, bottom: 20.0, child: _showLabels ? Text(pitchName, textAlign: TextAlign.center, style: TextStyle( color: !accidental ? Colors.black : Colors.white)) : Container()),], ); if (accidental) { return Container( width: keyWidth, margin: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: keyWidth * .1), child: Material( elevation: 6.0, borderRadius: borderRadius, shadowColor: Color(0x802196F3), child: pianoKey)); } return Container( width: keyWidth, child: pianoKey, margin: EdgeInsets.symmetric(horizontal: 2.0)); }

Now when you run the app it should look like this:

labels

Almost there! Now let's give our user some control.

Step 10 #

Add these settings to the Drawer


Container(height: 20.0),ListTile(title: Text("Change Width")),Slider( activeColor: Colors.redAccent, inactiveColor: Colors.white, min: 0.0, max: 1.0, value: _widthRatio, onChanged: (double value) => setState(() => _widthRatio = value)),Divider(),ListTile( title: Text("Show Labels"), trailing: Switch( value: _showLabels, onChanged: (bool value) => setState(() => _showLabels = value))),Divider(),

Now you should see this:

settings

Step 11 #

To start with Middle C lets add an inital scroll offset to the ListView

controller: ScrollController(initialScrollOffset: 1500.0),

Now when we start the app it should co to C4.

The final App should look like this:

final

The final code should look like this:


import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_midi/flutter_midi.dart';import 'package:tonic/tonic.dart';void main() => runApp(MyApp());class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState();}class _MyAppState extends State<MyApp> { @override initState() { FlutterMidi.unmute(); rootBundle.load("assets/sounds/Piano.sf2").then((sf2) { FlutterMidi.prepare(sf2: sf2, name: "Piano.sf2"); }); super.initState(); } double get keyWidth => 80 + (80 * _widthRatio); double _widthRatio = 0.0; bool _showLabels = true; @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: Text("Flutter Piano")), drawer: Drawer( child: SafeArea( child: ListView(children: <Widget>[Container(height: 20.0), ListTile(title: Text("Change Width")), Slider( activeColor: Colors.redAccent, inactiveColor: Colors.white, min: 0.0, max: 1.0, value: _widthRatio, onChanged: (double value) => setState(() => _widthRatio = value)), Divider(), ListTile( title: Text("Show Labels"), trailing: Switch( value: _showLabels, onChanged: (bool value) => setState(() => _showLabels = value))), Divider(),]))), body: ListView.builder( itemCount: 7, scrollDirection: Axis.horizontal, controller: ScrollController(initialScrollOffset: 1500.0), itemBuilder: (BuildContext context, int index) { final int i = index * 12; return SafeArea( child: Stack(children: <Widget>[Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ _buildKey(24 + i, false), _buildKey(26 + i, false), _buildKey(28 + i, false), _buildKey(29 + i, false), _buildKey(31 + i, false), _buildKey(33 + i, false), _buildKey(35 + i, false),]), Positioned( left: 0.0, right: 0.0, bottom: 100, top: 0.0, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: <Widget>[Container(width: keyWidth * .5), _buildKey(25 + i, true), _buildKey(27 + i, true), Container(width: keyWidth), _buildKey(30 + i, true), _buildKey(32 + i, true), _buildKey(34 + i, true), Container(width: keyWidth * .5),])), ]), ); }, )), ); } Widget _buildKey(int midi, bool accidental) { final pitchName = Pitch.fromMidiNumber(midi).toString(); final pianoKey = Stack( children: <Widget>[Semantics( button: true, hint: pitchName, child: Material( borderRadius: borderRadius, color: accidental ? Colors.black : Colors.white, child: InkWell( borderRadius: borderRadius, highlightColor: Colors.grey, onTap: () {}, onTapDown: (_) => FlutterMidi.playMidiNote(midi: midi), ))), Positioned( left: 0.0, right: 0.0, bottom: 20.0, child: _showLabels ? Text(pitchName, textAlign: TextAlign.center, style: TextStyle( color: !accidental ? Colors.black : Colors.white)) : Container()),], ); if (accidental) { return Container( width: keyWidth, margin: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: keyWidth * .1), child: Material( elevation: 6.0, borderRadius: borderRadius, shadowColor: Color(0x802196F3), child: pianoKey)); } return Container( width: keyWidth, child: pianoKey, margin: EdgeInsets.symmetric(horizontal: 2.0)); }}const BorderRadiusGeometry borderRadius = BorderRadius.only( bottomLeft: Radius.circular(10.0), bottomRight: Radius.circular(10.0));

Conclusion #

If you delete tests/ and run find . -name "*.dart" | xargs cat | wc -c you will see that the dart code only uses 5032 bytes of space!

Now we have a fully functional piano that you can play with and enjoy on iOS and Android.

I was really inspired when creating this for the Flutter Create contest.

Hope you learned something, if you have any questions you can always read out to me. This is an open source piano and would love PRs on the main project here!

Top comments (0)