音階を覚えるアプリを探していたが、
希望するものがなかったので作成してみた。
また、せっかくなのでFlutterなるフレームワークを使用してみることにした。

作り方が今までのAndroidアプリと違っていたので結構苦労した。
 ・タップイベントの取り方
 ・定期的に再描画させる方法(CustomPain)

とりあえずやっつけではあるが3時間もかけて作成したので、
ソースコードをアップしておく。

■画像について

音階記号や音符は以下のサイト様のものを利用させてもらいました。

https://illustimage.com/?id=2127

https://illustimage.com/?id=5556

https://illustimage.com/?id=5555

↑の画像を透明部分トリミングして使用しています

 

■ソース

import 'dart:math';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/painting.dart' show decodeImageFromList;
import 'dart:async';

void main() => runApp(ColorPickerPage());

class ColorPickerPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Theme.of(context).backgroundColor,
        appBar: AppBar(
          title: Text('InCard'),
          centerTitle: false,
          elevation: 0,
        ),
        body: Body()
      )
    );
  }
}

class Body extends StatefulWidget {
  @override
  _BodyState createState() => _BodyState();
}

class _BodyState extends State<Body> {
  final _counter = ValueNotifier<int>(0);
  final gsingPattern = [
    [4,4,4,4,4,4,4,5,5,5,5,5,5,5,6,6],
    [0,1,2,3,4,5,6,0,1,2,3,4,5,6,0,1]
  ];
  final fsignPattern = [
    [2,2,2,2,2,2,2,3,3,3,3,3,3,3,4],
    [0,1,2,3,4,5,6,0,1,2,3,4,5,6,1]
  ];
  final rand = Random();
  final List<int> pattern = [];

  _SamplePainter painter;
  int signType = 0;
  Timer timer;

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

    // _counterが変わる毎に再描画が行なわれる
    painter = _SamplePainter(repaint:_counter);

    _loadImage('images/fsign.png').then((value) {
      painter.setFsignImg(value);
    });
    _loadImage('images/gsign.png').then((value) {
      painter.setGsignImg(value);
    });
    _loadImage('images/note.png').then((value) {
      painter.setNoteImg(value);
    });

    timer = Timer.periodic(Duration(milliseconds: 400),(Timer timer){
      if (painter.isReady()) {
        if (pattern.isEmpty){
          if (signType == 0) {
            pattern.addAll(List.generate(gsingPattern.elementAt(0).length, (index) => index));
           }
          else if (signType == 1){
            pattern.addAll(List.generate(fsignPattern.elementAt(0).length, (index) => index));
           }
        }

        final int ridx = rand.nextInt(pattern.length);
        int idx = pattern.elementAt(ridx);
        pattern.removeAt(ridx);

        if (signType == 0) {
          painter.setCode(signType, gsingPattern.elementAt(0).elementAt(idx), gsingPattern.elementAt(1).elementAt(idx));
        }
        else if (signType == 1){
          painter.setCode(signType, fsignPattern.elementAt(0).elementAt(idx), fsignPattern.elementAt(1).elementAt(idx));
        }

        _counter.value++;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final canvasSize = Size(400,400);
    final size = MediaQuery.of(context).size;

    var scaleW = size.width / canvasSize.width;
    var scaleH = size.height / canvasSize.height;
    var scale = scaleW;
    if (scaleH < scaleW){
      scale = scaleH;
    }
    painter.setCanvasScale(scale);

    return GestureDetector(
      onTap: () {
        signType = 1 -  signType;
        pattern.clear();
      },
      child: CustomPaint(
        size: canvasSize,
        painter: painter,
      ),
    );
  }

  static Future<ui.Image> _loadImage(String name) async
  {
    final data = await rootBundle.load(name);
    return decodeImageFromList(data.buffer.asUint8List());
  }
}

class _SamplePainter extends CustomPainter {

  final names = ['ド','レ','ミ','ファ','ソ','ラ','シ'];
  final fsignC4_y = 120.0;
  final gsignC4_y = 240.0;
  final codeInterval = 10.0;

  ui.Image fsignImg;
  ui.Image gsignImg;
  ui.Image noteImg;

  double canvasScale = 1.0;
  int scale = 0;        // C1~C7
  int code = 0;         // 0:C 1:D ...
  int signType = 0; // 0:ト音記号 1:ヘ音記号

  _SamplePainter({Listenable repaint}) : super(repaint: repaint);

  void setCanvasScale(double scale) {
    canvasScale = scale;
  }

  void setFsignImg(ui.Image img){
    fsignImg = img;
  }

  void setGsignImg(ui.Image img){
    gsignImg = img;
  }

  void setNoteImg(ui.Image img){
    noteImg = img;
  }

  bool isReady(){
    if (fsignImg == null
    || gsignImg == null
    || noteImg == null){
      return false;
    }
    return true;
  }

  void setCode(int signType,int scale,int code) {
    if (signType < 0) signType = 0;
    else if (signType > 1) signType = 1;

    if (scale < 1) scale = 1;
    else if (scale > 7) scale = 7;

    if (code < 0) code = 0;
    else if (code > 7) code = 7;

    this.signType = signType;
    this.scale = scale;
    this.code = code;
  }

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..strokeWidth = 2.0
      ..color = Colors.black;
    final textPainter = TextPainter(
        textAlign: TextAlign.center, textDirection: TextDirection.ltr);
    final fontSize = 70.0;
    final codeText = [
      textPainter.text = TextSpan(style: TextStyle(color: Colors.red, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(0),),
      textPainter.text = TextSpan(style: TextStyle(color: Colors.black, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(1),),
      textPainter.text = TextSpan(style: TextStyle(color: Colors.black, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(2),),
      textPainter.text = TextSpan(style: TextStyle(color: Color.fromARGB(255, 0, 0, 255), fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(3),),
      textPainter.text = TextSpan(style: TextStyle(color: Colors.black, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(4),),
      textPainter.text = TextSpan(style: TextStyle(color: Colors.black, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(5),),
      textPainter.text = TextSpan(style: TextStyle(color: Colors.black, fontSize: fontSize, fontFamily: 'メイリオ'),text: names.elementAt(6),)
    ];

    if (!isReady()) {
      return;
    }

    final beginX = size.width * 0.95;
    final endX = size.width - beginX;
    final beginY = 150.0;

    final clefX = 160.0;
    double clefY = 0.0;

    canvas.scale(canvasScale, canvasScale);

    // ト音記号
    if (signType == 0) {
      clefY = (4 - scale) * (codeInterval * 7.0) + gsignC4_y;
      clefY -= (code * codeInterval);
    }
    // へ音記号
    else {
      clefY = (4 - scale) * (codeInterval * 7.0) + fsignC4_y;
      clefY -= (code * codeInterval);
    }

    // 5線の描画
    for (int i = 0; i < 5; i++) {
      canvas.drawLine(
          Offset(beginX, beginY + i * codeInterval * 2),
          Offset(endX, beginY + i * codeInterval * 2),
          paint);
    }

    // 音部記号の描画
    if (signType == 0) {
      canvas.drawImageRect(gsignImg,
          Rect.fromLTWH(
              0, 0, gsignImg.width.toDouble(), gsignImg.height.toDouble()),
          Rect.fromLTWH(40, 130, gsignImg.width.toDouble() * 0.40,
              gsignImg.height.toDouble() * 0.40),
          paint);
    }
    else {
      canvas.drawImageRect(fsignImg,
          Rect.fromLTWH(
              0, 0, fsignImg.width.toDouble(), fsignImg.height.toDouble()),
          Rect.fromLTWH(40, 150, fsignImg.width.toDouble() * 0.32,
              fsignImg.height.toDouble() * 0.32),
          paint);
    }

    // 加線の描画
    double tmpClefY = clefY;
    double addLineY = 130;
    while (tmpClefY <= 120.0) {
      canvas.drawLine(
          Offset(clefX - 10.0, addLineY),
          Offset(clefX + 35.0, addLineY),
          paint);
      tmpClefY += (codeInterval * 2);
      addLineY -= (codeInterval * 2);
    }
    tmpClefY = clefY;
    addLineY = 250.0;
    while (tmpClefY >= 240.0) {
      canvas.drawLine(
          Offset(clefX - 10.0, addLineY),
          Offset(clefX + 35.0, addLineY),
          paint);
      tmpClefY -= (codeInterval * 2);
      addLineY += (codeInterval * 2);
    }

    // 音符の描画
    canvas.drawImageRect(noteImg,
        Rect.fromLTWH(
            0, 0, noteImg.width.toDouble(), noteImg.height.toDouble()),
        Rect.fromLTWH(clefX, clefY, noteImg.width.toDouble() * 0.25,
            noteImg.height.toDouble() * 0.25),
        paint);

    // コード名の表示
    textPainter.text = codeText.elementAt(code);
    textPainter.layout();
    textPainter.paint(canvas, Offset(220.0, 130.0));
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

 

■pubsec.yaml

dev_dependencies:
  flutter_test:
    sdk: flutter

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  #↓追加
  assets:                
    - images/fsign.png
    - images/gsign.png
    - images/note.png