Flutterでアイコンをタップしたら音が出るシンプルなアプリを開発してリリースする 1/n アプリを開発する
Flutterでアイコンをタップしたら音が出るシンプルなアプリを開発してリリースするまでをまとめるシリーズの1記事目です
反省
以前から何かしらアプリを開発してリリースしたかったんですが、アイデアを考えて途中まで開発したものの、本業の仕事が忙しくなってしばらく離れ、段々とやる気がなくなり、中途半端に投げ出す、ということを繰り返してきました。同じような方も多いのではないかなと思います。
ゴール
そこで今回は、開発するアプリ自体はかなりシンプルなものにして、とにかくまず1つリリースすることをゴールとします。アプリの個人開発は特に、最初の1つをリリースするまでのハードルがすごく高く、1つ出している人は2つ3つとどんどん出している、という話をよく聞きます。今回で私もその1つ目のハードルをまず超えることをゴールとします。「なるほどこんなんでいいのね」と思って貰えたら幸いです。
作るアプリの概要
かなりシンプルなアプリにするとは言っても、誰も使い手がいないのは寂しいです。そこで私は、現在1歳1ヶ月の息子向けのアプリを開発することにしました!息子は普段からかなりスマホに興味津々で、いつも奪い取ろうとしてきます。ちょうどそろそろ、指差しの練習をしたいと思っていたところでした。そんな時に使えるこんなアプリを作っていきます。
現時点ではまだデモ段階で、ここに乗り物のページを追加して、タブで切り替えられるようにしようと思ってます。でも、それだけです。データストアも認証も状態管理ライブラリも何も使っていない、アイコンをタップしたら音が出るだけの、非常にシンプルなアプリです。
ちなみに音声は効果音ラボ(@soundeffect_lab)さん、イラストはいらすとや(@irasutoya)さんからお借りしています...! ありがとうございます🙇🏻♂️
全ての記事を #first_flutter_tutorial というタグに紐づけます。
ソースコードは全て以下で公開しています。
https://github.com/tkugimot/touch_and_hear
構築
FlutterでHello World の記事に記載したのと同じ要領で新しいアプリを作成してGitHubにPUSHしておきます。アプリ名は「touch_and_hear」にします。
$ flutter create touch-and-hear
$ git init
$ git add .
$ git commit -m 'Initial Commit'
$ git remote add origin git@github.com:tkugimot/touch_and_hear.git
$ git push origin main
$ git checkout -b feature/sound-app
準備
効果音ラボさんから、動物の音声6つ、乗り物の音声6つをダウンロードします。
続いて、いらすとやさんから対応するイラストを12個ダウンロードします。
開発
assetsディレクトリへ画像と音声ファイルを配置
まず、先ほどダウンロードした画像と音声ファイルをルート直下の assets ディレクトリに配置します。
$ git status
On branch feature/sound-app
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: assets/images/bicycle.png
new file: assets/images/bike.png
new file: assets/images/cat.png
new file: assets/images/dog.png
new file: assets/images/elephant.png
new file: assets/images/fune.png
new file: assets/images/horse.png
new file: assets/images/kyukyusya.png
new file: assets/images/leo.png
new file: assets/images/patocar.png
new file: assets/images/piyo.png
new file: assets/images/super_car.png
new file: assets/sounds/bicycle.mp3
new file: assets/sounds/bike.mp3
new file: assets/sounds/cat.mp3
new file: assets/sounds/dog.mp3
new file: assets/sounds/elephant.mp3
new file: assets/sounds/fune.mp3
new file: assets/sounds/horse.mp3
new file: assets/sounds/kyukyusya.mp3
new file: assets/sounds/leo.mp3
new file: assets/sounds/patocar.mp3
new file: assets/sounds/piyo.mp3
new file: assets/sounds/super_car.mp3
$ git add .
$ git commit -m 'Add assets'
pubspec.yamlの修正
続いて、pubspec.yaml を修正して assets をアプリから読み込めるように設定します。
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
+ assets:
+ - assets/sounds/
+ - assets/images/
soundpool ライブラリの導入
次に、音楽を再生するのに必要な soundpool というライブラリをインストールします。
$ flutter pub add soundpool
$ git add .
$ git commit -m 'Update pubspec.yaml'
※以降はコミットコマンドまでは書かないので、適当なタイミングでコミットしてください。
音楽の再生処理を担う Sound クラスの実装
次に、音楽の再生処理を担う Sound クラスを作成します。
実装は https://gaprot.jp/2021/09/07/flutter_se/ こちらのサイトを参考にさせて頂きました。ありがとうございます🙇🏻♂️
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:soundpool/soundpool.dart';
enum SoundIds {
DogButton,
CatButton,
ElephantButton,
LeoButton,
HorseButton,
PiyoButton,
BicycleButton,
BikeButton,
FuneButton,
KyuKyuSyaButton,
PatocarButton,
SuperCarButton
}
class Sound {
String os = Platform.operatingSystem;
bool isIOS = Platform.isIOS;
late Soundpool _soundPool;
final Map<SoundIds, int> _seContainer = Map<SoundIds, int>();
final Map<int, int> _streamContainer = Map<int, int>();
Sound() {
this._soundPool = Soundpool.fromOptions(
options: SoundpoolOptions(
streamType: StreamType.music, maxStreams: 5 // 5音同時発音に対応させる
));
() async {
var dogButton = await rootBundle
.load("assets/sounds/dog.mp3")
.then((value) => this._soundPool.load(value));
var catButton = await rootBundle
.load("assets/sounds/cat.mp3")
.then((value) => this._soundPool.load(value));
var elephantButton = await rootBundle
.load("assets/sounds/elephant.mp3")
.then((value) => this._soundPool.load(value));
var leoButton = await rootBundle
.load("assets/sounds/leo.mp3")
.then((value) => this._soundPool.load(value));
var horseButton = await rootBundle
.load("assets/sounds/horse.mp3")
.then((value) => this._soundPool.load(value));
var piyoButton = await rootBundle
.load("assets/sounds/piyo.mp3")
.then((value) => this._soundPool.load(value));
var bicycleButton = await rootBundle
.load("assets/sounds/bicycle.mp3")
.then((value) => this._soundPool.load(value));
var bikeButton = await rootBundle
.load("assets/sounds/bike.mp3")
.then((value) => this._soundPool.load(value));
var funeButton = await rootBundle
.load("assets/sounds/fune.mp3")
.then((value) => this._soundPool.load(value));
var kyukyusyaButton = await rootBundle
.load("assets/sounds/kyukyusya.mp3")
.then((value) => this._soundPool.load(value));
var patocarButton = await rootBundle
.load("assets/sounds/patocar.mp3")
.then((value) => this._soundPool.load(value));
var superCarButton = await rootBundle
.load("assets/sounds/super_car.mp3")
.then((value) => this._soundPool.load(value));
this._seContainer[SoundIds.DogButton] = dogButton;
this._seContainer[SoundIds.CatButton] = catButton;
this._seContainer[SoundIds.ElephantButton] = elephantButton;
this._seContainer[SoundIds.LeoButton] = leoButton;
this._seContainer[SoundIds.HorseButton] = horseButton;
this._seContainer[SoundIds.PiyoButton] = piyoButton;
this._seContainer[SoundIds.BicycleButton] = bicycleButton;
this._seContainer[SoundIds.BikeButton] = bikeButton;
this._seContainer[SoundIds.FuneButton] = funeButton;
this._seContainer[SoundIds.KyuKyuSyaButton] = kyukyusyaButton;
this._seContainer[SoundIds.PatocarButton] = patocarButton;
this._seContainer[SoundIds.SuperCarButton] = superCarButton;
this._streamContainer[dogButton] = 0;
this._streamContainer[catButton] = 1;
this._streamContainer[elephantButton] = 2;
this._streamContainer[leoButton] = 3;
this._streamContainer[horseButton] = 4;
this._streamContainer[piyoButton] = 5;
this._streamContainer[bicycleButton] = 6;
this._streamContainer[bikeButton] = 7;
this._streamContainer[funeButton] = 8;
this._streamContainer[kyukyusyaButton] = 9;
this._streamContainer[patocarButton] = 10;
this._streamContainer[superCarButton] = 11;
}();
}
void play(SoundIds ids) async {
var seId = this._seContainer[ids];
if (seId != null) {
var streamId = this._streamContainer[seId] ?? 0;
if (streamId > 0 && isIOS) {
await _soundPool.stop(streamId);
}
this._streamContainer[seId] = await _soundPool.play(seId);
} else {
print("se resource not found! ids: $ids");
}
}
Future<void> dispose() async {
await _soundPool.release();
_soundPool.dispose();
return Future.value(0);
}
}
見た目の実装
最後に、アイコンを表示してタップされたらSoundクラスに実装した再生処理を実行する処理をmain.dartに書きます。
import 'package:flutter/material.dart';
import 'Sound.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ぴよぴよサウンドパーク',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: const MyHomePage(title: 'ぴよぴよサウンドパーク'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
Sound sound = Sound();
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
static const TextStyle optionStyle =
TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
final List<Widget> _widgetOptions = <Widget>[
GridView.count(
// Create a grid with 2 columns. If you change the scrollDirection to
// horizontal, this produces 2 rows.
crossAxisCount: 2,
// Generate 100 widgets that display their index in the List.
children: [
IconButton(
icon: Image.asset('assets/images/dog.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.DogButton);
}),
IconButton(
icon: Image.asset('assets/images/cat.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.CatButton);
}),
IconButton(
icon: Image.asset('assets/images/elephant.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.ElephantButton);
}),
IconButton(
icon: Image.asset('assets/images/leo.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.LeoButton);
}),
IconButton(
icon: Image.asset('assets/images/horse.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.HorseButton);
}),
IconButton(
icon: Image.asset('assets/images/piyo.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.PiyoButton);
}),
]),
GridView.count(
// Create a grid with 2 columns. If you change the scrollDirection to
// horizontal, this produces 2 rows.
crossAxisCount: 2,
// Generate 100 widgets that display their index in the List.
children: [
IconButton(
icon: Image.asset('assets/images/bicycle.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.BicycleButton);
}),
IconButton(
icon: Image.asset('assets/images/bike.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.BikeButton);
}),
IconButton(
icon: Image.asset('assets/images/fune.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.FuneButton);
}),
IconButton(
icon: Image.asset('assets/images/kyukyusya.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.KyuKyuSyaButton);
}),
IconButton(
icon: Image.asset('assets/images/patocar.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.PatocarButton);
}),
IconButton(
icon: Image.asset('assets/images/super_car.png'),
iconSize: 50,
onPressed: () {
sound.play(SoundIds.SuperCarButton);
}),
]),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: _widgetOptions.elementAt(_selectedIndex),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.pets),
label: 'どうぶつ',
),
BottomNavigationBarItem(
icon: Icon(Icons.directions_car),
label: 'のりもの',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.amber[800],
onTap: _onItemTapped,
),
);
}
}
以上でアプリの開発自体は完了です。一応、以下が今回の修正分のPull Requestです。
https://github.com/tkugimot/touch_and_hear/pull/1?w=1
次回以降の記事ではリリースまでに必要なこととして以下を書いていきます。
- 多言語対応 (日本語 + 英語)
- Crashlyticsの導入 (監視のため)
- AdMobの導入 (広告のため)
- アイコン/フィーチャーグラフィックの作成・設定
- サポートサイト/プライバシーポリシーサイトの作成
- Drawerの追加(アプリの使い方/お問い合わせ/プライバシーポリシーへのリンク)
- Androidへのリリース
- iOSへのリリース